@openclaw/fs-safe 0.5.1 → 0.5.2
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 +66 -0
- package/README.md +18 -6
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +20 -12
- package/dist/archive-entry.d.ts +1 -0
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +25 -5
- package/dist/archive-input.d.ts +7 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +11 -1
- package/dist/archive-limits.js +1 -1
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +6 -3
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +2 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +30 -12
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +3 -2
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +7 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +7 -4
- package/dist/archive-zip-entry.d.ts +1 -0
- package/dist/archive-zip-entry.d.ts.map +1 -1
- package/dist/archive-zip-integrity.d.ts +4 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -0
- package/dist/archive-zip-integrity.js +44 -0
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +6 -1
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +5 -2
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +13 -2
- package/dist/deny-mutations.d.ts +1 -0
- package/dist/deny-mutations.d.ts.map +1 -1
- package/dist/deny-mutations.js +6 -39
- package/dist/device-path.d.ts +1 -0
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +6 -6
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +5 -4
- package/dist/error-detail.d.ts +2 -0
- package/dist/error-detail.d.ts.map +1 -0
- package/dist/error-detail.js +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +4 -1
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +88 -35
- package/dist/file-store-boundary.d.ts +6 -0
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +20 -5
- package/dist/file-store-sync-write.d.ts +9 -0
- package/dist/file-store-sync-write.d.ts.map +1 -0
- package/dist/file-store-sync-write.js +97 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +36 -128
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +27 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +5 -6
- package/dist/install-path.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +19 -13
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +1 -3
- package/dist/local-file-access.d.ts +3 -2
- package/dist/local-file-access.d.ts.map +1 -1
- package/dist/local-file-access.js +12 -6
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +35 -30
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +51 -12
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +1 -0
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +14 -1
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +44 -17
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +6 -5
- package/dist/path-policy.d.ts.map +1 -1
- package/dist/path-policy.js +1 -7
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -0
- package/dist/permission-exec.d.ts +6 -0
- package/dist/permission-exec.d.ts.map +1 -0
- package/dist/permission-exec.js +28 -0
- package/dist/permissions-windows.d.ts +56 -0
- package/dist/permissions-windows.d.ts.map +1 -0
- package/dist/permissions-windows.js +285 -0
- package/dist/permissions.d.ts +3 -46
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +17 -328
- package/dist/pinned-write.js +2 -1
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +21 -2
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +10 -1
- package/dist/replace-file-copy-fallback.d.ts +3 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +18 -11
- package/dist/replace-file-descriptor.d.ts +33 -0
- package/dist/replace-file-descriptor.d.ts.map +1 -0
- package/dist/replace-file-descriptor.js +96 -0
- package/dist/replace-file.d.ts +9 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +64 -72
- package/dist/root-context.d.ts +8 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +43 -2
- package/dist/root-errors.d.ts +7 -0
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +46 -1
- package/dist/root-file.d.ts +1 -0
- package/dist/root-file.d.ts.map +1 -1
- package/dist/root-file.js +2 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +213 -168
- package/dist/root-path-symlink.d.ts +3 -0
- package/dist/root-path-symlink.d.ts.map +1 -0
- package/dist/root-path-symlink.js +35 -0
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +129 -190
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +15 -0
- package/dist/safe-path-segment.d.ts +2 -0
- package/dist/safe-path-segment.d.ts.map +1 -1
- package/dist/safe-path-segment.js +12 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +19 -3
- package/dist/secure-temp-dir.d.ts.map +1 -1
- package/dist/secure-temp-dir.js +6 -1
- package/dist/short-path.d.ts +2 -0
- package/dist/short-path.d.ts.map +1 -0
- package/dist/short-path.js +7 -0
- package/dist/sidecar-lock-acquire.d.ts +30 -0
- package/dist/sidecar-lock-acquire.d.ts.map +1 -0
- package/dist/sidecar-lock-acquire.js +260 -0
- package/dist/sidecar-lock-policy.d.ts +3 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +20 -3
- package/dist/sidecar-lock-reclaim.d.ts +5 -1
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +59 -9
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +27 -226
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +15 -4
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/timing.d.ts +2 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +9 -0
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +15 -0
- package/docs/advanced.md +1 -1
- package/docs/archive.md +27 -20
- package/docs/assets/readme-banner.jpg +0 -0
- package/docs/atomic.md +48 -19
- package/docs/errors.md +31 -7
- package/docs/file-store.md +10 -1
- package/docs/filename.md +17 -16
- package/docs/index.md +1 -0
- package/docs/install-path.md +9 -6
- package/docs/json.md +8 -2
- package/docs/local-roots.md +79 -103
- package/docs/native-helper.md +11 -5
- package/docs/native.md +2 -2
- package/docs/output.md +8 -3
- package/docs/path-scope.md +4 -1
- package/docs/path.md +7 -3
- package/docs/permissions.md +8 -0
- package/docs/public-api.md +109 -0
- package/docs/quickstart.md +6 -2
- package/docs/reading.md +13 -10
- package/docs/root.md +26 -4
- package/docs/secret-file.md +17 -4
- package/docs/security-model.md +21 -4
- package/docs/sidecar-lock.md +15 -5
- package/docs/temp.md +37 -5
- package/docs/test-hooks.md +2 -0
- package/docs/types.md +32 -15
- package/docs/writing.md +25 -12
- package/package.json +7 -3
- package/dist/archive-file-io.d.ts +0 -9
- package/dist/archive-file-io.d.ts.map +0 -1
- package/dist/archive-file-io.js +0 -11
- package/dist/file-store-source.d.ts +0 -5
- package/dist/file-store-source.d.ts.map +0 -1
- package/dist/file-store-source.js +0 -30
- package/dist/file-url.d.ts +0 -2
- package/dist/file-url.d.ts.map +0 -1
- package/dist/file-url.js +0 -1
- package/dist/path-stat.d.ts +0 -4
- package/dist/path-stat.d.ts.map +0 -1
- package/dist/path-stat.js +0 -15
- package/dist/windows-permissions-native.d.ts +0 -9
- package/dist/windows-permissions-native.d.ts.map +0 -1
- package/dist/windows-permissions-native.js +0 -31
package/docs/atomic.md
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
|
|
15
15
|
## `replaceFileAtomic` / `replaceFileAtomicSync`
|
|
16
16
|
|
|
17
|
-
Write `content` to a sibling temp file in the destination directory, optionally `fsync` the
|
|
17
|
+
Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
|
|
18
|
+
|
|
19
|
+
On POSIX, the parent is opened with no-follow and directory-only flags, checked against its pre-open identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
|
|
18
20
|
|
|
19
21
|
Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
|
|
20
22
|
|
|
@@ -36,18 +38,19 @@ await replaceFileAtomic({
|
|
|
36
38
|
type ReplaceFileAtomicOptions = {
|
|
37
39
|
filePath: string; // destination
|
|
38
40
|
content: string | Uint8Array;
|
|
39
|
-
dirMode?: number; //
|
|
40
|
-
mode?: number; //
|
|
41
|
-
preserveExistingMode?: boolean; // copy
|
|
42
|
-
tempPrefix?: string;
|
|
43
|
-
renameMaxRetries?: number;
|
|
44
|
-
renameRetryBaseDelayMs?: number;
|
|
45
|
-
copyFallbackOnPermissionError?: boolean;
|
|
41
|
+
dirMode?: number; // parent-directory mode (POSIX; default 0o700)
|
|
42
|
+
mode?: number; // new-file mode (default 0o600)
|
|
43
|
+
preserveExistingMode?: boolean; // copy existing mode; default false
|
|
44
|
+
tempPrefix?: string; // default ".fs-safe-replace"
|
|
45
|
+
renameMaxRetries?: number; // EBUSY retries; default 0
|
|
46
|
+
renameRetryBaseDelayMs?: number; // exponential base; default 50
|
|
47
|
+
copyFallbackOnPermissionError?: boolean; // default false
|
|
46
48
|
copyFallbackRestore?: "restore-original" | "none"; // default: "none"
|
|
47
49
|
maxRestoreBytes?: number; // required with "restore-original"
|
|
48
|
-
destinationHardlinks?: "reject";
|
|
49
|
-
syncTempFile?: boolean; // fsync(temp) before rename
|
|
50
|
-
syncParentDir?: boolean; // fsync(parent) after rename
|
|
50
|
+
destinationHardlinks?: "reject"; // default unset (no destination nlink policy)
|
|
51
|
+
syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
|
|
52
|
+
syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
|
|
53
|
+
throwOnCleanupError?: boolean; // report temp cleanup failure; default false
|
|
51
54
|
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
|
52
55
|
fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
|
|
53
56
|
};
|
|
@@ -87,9 +90,10 @@ The default `copyFallbackRestore: "none"` preserves the existing fallback
|
|
|
87
90
|
contract: a failed copy can leave a partial destination. For state files where
|
|
88
91
|
preserving the old bytes is more important, choose `"restore-original"` and set
|
|
89
92
|
an explicit `maxRestoreBytes` memory budget. If the destination exists, fs-safe
|
|
90
|
-
snapshots it through a pinned descriptor, overwrites
|
|
91
|
-
descriptor, and synchronizes the result. Any write or sync
|
|
92
|
-
restore and another sync through the same
|
|
93
|
+
snapshots it through a pinned descriptor, overwrites and mode-adjusts through
|
|
94
|
+
that same descriptor, and synchronizes the result. Any write, mode, or sync
|
|
95
|
+
failure triggers a byte-and-mode restore and another sync through the same
|
|
96
|
+
descriptor.
|
|
93
97
|
|
|
94
98
|
Restore failures are `FsSafeError("helper-failed")` values with typed
|
|
95
99
|
`details.cleanup` set to `"restored"` or `"restore-failed"`. An original larger
|
|
@@ -99,7 +103,10 @@ fallback.
|
|
|
99
103
|
|
|
100
104
|
### Sync variant
|
|
101
105
|
|
|
102
|
-
`replaceFileAtomicSync` accepts the same options
|
|
106
|
+
`replaceFileAtomicSync` accepts the same base options, a synchronous
|
|
107
|
+
`beforeRename` callback, and `ReplaceFileAtomicSyncFileSystem`. Use it inside
|
|
108
|
+
synchronous boot paths or test setup code. It returns the same
|
|
109
|
+
`{ method: "rename" | "copy-fallback" }` receipt as the async variant.
|
|
103
110
|
|
|
104
111
|
## `replaceDirectoryAtomic`
|
|
105
112
|
|
|
@@ -115,13 +122,17 @@ await replaceDirectoryAtomic({
|
|
|
115
122
|
```
|
|
116
123
|
|
|
117
124
|
The helper renames `targetDir` to a generated backup path, renames `stagedDir → targetDir`, then removes the backup. If the second rename fails, it tries to restore the original target before rethrowing.
|
|
125
|
+
Concurrent replacements of the same resolved target are serialized inside the
|
|
126
|
+
current process so their backup, commit, and cleanup phases cannot interleave.
|
|
127
|
+
`backupPrefix`, when supplied, is sanitized as one path prefix and cannot contain
|
|
128
|
+
path separators or NUL bytes; the generated backup tail is randomized.
|
|
118
129
|
|
|
119
130
|
Use it when callers must see a whole staged tree at the target path. For single-file replacement, `replaceFileAtomic` is the right tool.
|
|
120
131
|
|
|
121
132
|
## `writeTextAtomic`
|
|
122
133
|
|
|
123
134
|
Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
|
|
124
|
-
temp file,
|
|
135
|
+
temp file, descriptor-bound mode setting and fsync, rename, and parent fsync.
|
|
125
136
|
It delegates to `replaceFileAtomic()` with a smaller call shape. Use it when
|
|
126
137
|
you do not need replacement hooks such as `beforeRename`, `preserveExistingMode`,
|
|
127
138
|
or custom copy-fallback policy.
|
|
@@ -141,8 +152,8 @@ Options:
|
|
|
141
152
|
```ts
|
|
142
153
|
type WriteTextAtomicOptions = {
|
|
143
154
|
mode?: number; // file mode (default 0o600)
|
|
144
|
-
dirMode?: number; // mode
|
|
145
|
-
trailingNewline?: boolean; // append "\n" if missing
|
|
155
|
+
dirMode?: number; // parent mode (default 0o777 masked by process umask)
|
|
156
|
+
trailingNewline?: boolean; // append "\n" if missing; default false
|
|
146
157
|
durable?: boolean; // default true; false skips temp/parent fsync
|
|
147
158
|
};
|
|
148
159
|
```
|
|
@@ -157,7 +168,14 @@ Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
|
|
|
157
168
|
copying into a staged sibling path, renaming that staged path into place, and
|
|
158
169
|
then removing only the source entries that were copied. The fallback avoids
|
|
159
170
|
buffering regular files into memory and does not tighten the destination parent
|
|
160
|
-
directory mode.
|
|
171
|
+
directory mode. Staged file modes are applied through their still-open handles.
|
|
172
|
+
If descriptor-bound mode application fails, the staged path is removed and the
|
|
173
|
+
move fails before publication. A transient staged-path cleanup failure retains
|
|
174
|
+
an identity-bound process-exit cleanup retry.
|
|
175
|
+
On POSIX, staged directory modes are applied through no-follow directory
|
|
176
|
+
descriptors; on Windows, Node cannot portably open those descriptors and no
|
|
177
|
+
pathname `chmod` fallback is attempted, so directory modes remain subject to
|
|
178
|
+
Windows' `mkdir(mode)` behavior.
|
|
161
179
|
|
|
162
180
|
```ts
|
|
163
181
|
import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
|
|
@@ -212,6 +230,17 @@ await replaceFileAtomic({
|
|
|
212
230
|
});
|
|
213
231
|
```
|
|
214
232
|
|
|
233
|
+
The synchronous injectable interface has one optional descriptor-mode operation:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
type ReplaceFileAtomicSyncFileSystem = {
|
|
237
|
+
// other required operations omitted
|
|
238
|
+
fchmodSync?: typeof import("node:fs").fchmodSync;
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The async interface already requires `open()`, whose `FileHandle` supplies `chmod()`, so injecting `node:fs` or another conforming adapter needs no new async member. On POSIX, that `open()` must support no-follow directory descriptors as Node does. A custom synchronous filesystem that passes `mode`, `dirMode`, or `preserveExistingMode` must supply `fchmodSync`; omission fails before any file or directory is created and never falls back to a pathname `chmod`. Existing synchronous adapters that request none of those options may omit it; their parent is still opened and identity-checked through a no-follow directory descriptor. Injecting plain `node:fs` supports explicit file and directory modes. Older adapter literals may continue to include `chmod` or `chmodSync` for source compatibility, but those operations are ignored. Copy fallback applies the file mode through its pinned destination descriptor as well, preserving exact modes despite the process umask.
|
|
243
|
+
|
|
215
244
|
## See also
|
|
216
245
|
|
|
217
246
|
- [`root()`](root.md) — when you want method-style writes with the boundary baked in.
|
package/docs/errors.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
|
|
4
4
|
|
|
5
|
+
Path and archive-entry details embedded in diagnostics escape control characters
|
|
6
|
+
as `\\uXXXX` sequences. This keeps attacker-controlled names on one log line;
|
|
7
|
+
the escaped message is for diagnosis, not for reconstructing the original path.
|
|
8
|
+
|
|
5
9
|
```ts
|
|
6
10
|
import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";
|
|
7
11
|
```
|
|
@@ -50,8 +54,17 @@ destination.
|
|
|
50
54
|
|
|
51
55
|
`category` separates caller-policy failures from operational failures:
|
|
52
56
|
|
|
53
|
-
- `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
|
|
54
|
-
- `"operational"` — environment/runtime failures, such as helper startup, platform support, timeout, or unverifiable permissions.
|
|
57
|
+
- `"policy"` — unsafe input or target state rejected by a safety policy, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
|
|
58
|
+
- `"operational"` — routine filesystem outcomes or environment/runtime failures, such as `not-found`, `not-empty`, `not-removable`, helper startup, platform support, timeout, or unverifiable permissions.
|
|
59
|
+
|
|
60
|
+
Routine absence or inability to remove a path does not by itself indicate a
|
|
61
|
+
filesystem boundary violation. Branch on the specific code when the distinction
|
|
62
|
+
between those operational outcomes matters.
|
|
63
|
+
|
|
64
|
+
The operational set is exactly `helper-failed`, `helper-unavailable`,
|
|
65
|
+
`not-empty`, `not-found`, `not-removable`, `permission-unverified`, `timeout`,
|
|
66
|
+
and `unsupported-platform`. Every other current `FsSafeErrorCode`, including
|
|
67
|
+
`store-reentrant-update`, is categorized as `policy`.
|
|
55
68
|
|
|
56
69
|
## Code union
|
|
57
70
|
|
|
@@ -75,6 +88,7 @@ type FsSafeErrorCode =
|
|
|
75
88
|
| "path-mismatch"
|
|
76
89
|
| "permission-unverified"
|
|
77
90
|
| "secret-exists"
|
|
91
|
+
| "store-reentrant-update"
|
|
78
92
|
| "symlink"
|
|
79
93
|
| "timeout"
|
|
80
94
|
| "too-large"
|
|
@@ -92,7 +106,7 @@ type FsSafeErrorCode =
|
|
|
92
106
|
| `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
|
|
93
107
|
| `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible bundled binary, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
|
|
94
108
|
| `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. |
|
|
95
|
-
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
|
|
109
|
+
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was 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 legal POSIX drive-like names. |
|
|
96
110
|
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
|
|
97
111
|
| `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
|
|
98
112
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
|
|
@@ -103,10 +117,11 @@ type FsSafeErrorCode =
|
|
|
103
117
|
| `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. |
|
|
104
118
|
| `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
|
|
105
119
|
| `secret-exists` | `createSecretFileAtomic()` found an existing final path. | First-writer-wins secret creation lost a race or the credential was already initialized. |
|
|
120
|
+
| `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. |
|
|
106
121
|
| `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
|
|
107
122
|
| `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
|
|
108
123
|
| `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
|
|
109
|
-
| `unsupported-platform` |
|
|
124
|
+
| `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
|
|
110
125
|
|
|
111
126
|
## Branching
|
|
112
127
|
|
|
@@ -138,11 +153,13 @@ try {
|
|
|
138
153
|
}
|
|
139
154
|
```
|
|
140
155
|
|
|
141
|
-
The
|
|
156
|
+
The `default` above deliberately rethrows unknown codes, so it remains safe as
|
|
157
|
+
the union grows. If you want the compiler to flag every newly added code, end
|
|
158
|
+
an exhaustive switch with a `never` assertion instead of a general `default`.
|
|
142
159
|
|
|
143
160
|
## Distinguishing from `NodeJS.ErrnoException`
|
|
144
161
|
|
|
145
|
-
Some failures bubble up as native Node errors (e.g. `EACCES`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
|
|
162
|
+
Some failures bubble up as native Node errors (e.g. `EACCES`, `EPERM`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
|
|
146
163
|
|
|
147
164
|
```ts
|
|
148
165
|
import { FsSafeError } from "@openclaw/fs-safe";
|
|
@@ -154,7 +171,8 @@ try {
|
|
|
154
171
|
handleFsSafe(err);
|
|
155
172
|
return;
|
|
156
173
|
}
|
|
157
|
-
|
|
174
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
175
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
158
176
|
handleAccess();
|
|
159
177
|
return;
|
|
160
178
|
}
|
|
@@ -164,6 +182,12 @@ try {
|
|
|
164
182
|
|
|
165
183
|
A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.
|
|
166
184
|
|
|
185
|
+
On Windows, access-denied failures from native root/open filesystem operations
|
|
186
|
+
use `EPERM` to match Node/libuv and the JavaScript fallback. Security-descriptor
|
|
187
|
+
inspection has its own failure surface. Older fs-safe versions could report
|
|
188
|
+
`EACCES` for the same root/open condition, so consumers spanning versions
|
|
189
|
+
should accept both codes.
|
|
190
|
+
|
|
167
191
|
## Specialty errors
|
|
168
192
|
|
|
169
193
|
A handful of helpers throw their own typed errors instead of `FsSafeError`:
|
package/docs/file-store.md
CHANGED
|
@@ -43,7 +43,7 @@ type FileStore = {
|
|
|
43
43
|
readonly rootDir: string;
|
|
44
44
|
path(relativePath: string): string;
|
|
45
45
|
root(): Promise<Root>;
|
|
46
|
-
write(rel, data: string |
|
|
46
|
+
write(rel, data: string | Uint8Array, options?): Promise<string>;
|
|
47
47
|
writeStream(rel, stream: Readable, options?): Promise<string>;
|
|
48
48
|
copyIn(rel, sourcePath: string, options?): Promise<string>;
|
|
49
49
|
open(rel, options?): Promise<OpenResult>;
|
|
@@ -64,6 +64,15 @@ type FileStore = {
|
|
|
64
64
|
|
|
65
65
|
`path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
|
|
66
66
|
|
|
67
|
+
Every `relativePath` is a portable store key, including keys passed to reads,
|
|
68
|
+
`exists`, and `remove`. A segment with a Windows drive-relative spelling such
|
|
69
|
+
as `C:name` (including an embedded segment such as `a/C:name`) throws
|
|
70
|
+
`invalid-path` on every platform. This prevents a key created on POSIX from
|
|
71
|
+
aliasing a different file when the store is moved to Windows. Colons elsewhere,
|
|
72
|
+
such as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain valid.
|
|
73
|
+
Keys with surrounding whitespace also throw `invalid-path`; the store never
|
|
74
|
+
silently trims one caller-supplied key onto another key.
|
|
75
|
+
|
|
67
76
|
`root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
|
|
68
77
|
|
|
69
78
|
## Writes
|
package/docs/filename.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Filenames
|
|
2
2
|
|
|
3
|
-
`sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one
|
|
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; pair with [`safeDirName`](install-path.md#safedirname) when you need stricter directory-name handling.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe";
|
|
6
|
+
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
|
|
7
7
|
|
|
8
8
|
const safe = sanitizeUntrustedFileName(req.body.fileName, "upload");
|
|
9
9
|
await fs.write(`uploads/${safe}`, body);
|
|
@@ -24,9 +24,12 @@ In order:
|
|
|
24
24
|
3. **Strip non-portable characters.** C0/C1 controls (`0x00`–`0x1f`, `0x7f`–`0x9f`) and the Windows-invalid set `< > : " / \\ | ? *` are removed on every platform.
|
|
25
25
|
4. **Trim again.**
|
|
26
26
|
5. If the result is empty, `"."`, or `".."`, return `fallbackName`.
|
|
27
|
-
6. **
|
|
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
|
+
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.
|
|
28
29
|
|
|
29
|
-
That's it. The function stays intentionally small: it
|
|
30
|
+
That's it. The function stays intentionally small: it removes traversal and
|
|
31
|
+
the most obvious cross-platform device and character hazards, but it is not a
|
|
32
|
+
complete portable-filename or uniqueness policy.
|
|
30
33
|
|
|
31
34
|
## Examples
|
|
32
35
|
|
|
@@ -38,35 +41,33 @@ sanitizeUntrustedFileName("a\u0000b\tc", "upload"); // "abc"
|
|
|
38
41
|
sanitizeUntrustedFileName(" ", "fallback"); // "fallback"
|
|
39
42
|
sanitizeUntrustedFileName(".", "fallback"); // "fallback"
|
|
40
43
|
sanitizeUntrustedFileName("..", "fallback"); // "fallback"
|
|
41
|
-
sanitizeUntrustedFileName("
|
|
44
|
+
sanitizeUntrustedFileName("CON", "fallback"); // "CON_"
|
|
45
|
+
sanitizeUntrustedFileName("nul.txt", "fallback"); // "nul_.txt"
|
|
46
|
+
sanitizeUntrustedFileName("aux.c", "fallback"); // "aux_.c"
|
|
47
|
+
sanitizeUntrustedFileName("conin$", "fallback"); // "conin$_"
|
|
48
|
+
sanitizeUntrustedFileName("a".repeat(300), "x"); // 200-char "aaa..."
|
|
42
49
|
```
|
|
43
50
|
|
|
44
51
|
## What it does **not** do
|
|
45
52
|
|
|
46
53
|
The function is deliberately narrow. It will not:
|
|
47
54
|
|
|
48
|
-
- Reject Windows reserved names (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
|
|
49
55
|
- Replace leading dots (so a name like `.config` stays hidden on POSIX systems).
|
|
50
|
-
- Trim trailing dots
|
|
56
|
+
- Trim trailing dots. Surrounding spaces are removed by the documented trim
|
|
57
|
+
steps, but Windows-normalized dot/space spellings can still alias; reject or
|
|
58
|
+
rewrite them when Windows portability or cross-platform migration matters.
|
|
51
59
|
- Add an extension or change case.
|
|
52
60
|
- Validate file *content*. To enforce an extension allow-list, check after sanitization.
|
|
53
61
|
- Deduplicate against existing files. Append a random suffix if you need uniqueness.
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
const trimmed = sanitizeUntrustedFileName(input, "upload");
|
|
59
|
-
const noWindowsReserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i.test(trimmed)
|
|
60
|
-
? "upload"
|
|
61
|
-
: trimmed;
|
|
62
|
-
```
|
|
63
|
+
Windows reserved basenames are handled by the default portability pass; callers no longer need to layer a separate reserved-name recipe on top.
|
|
63
64
|
|
|
64
65
|
## Common patterns
|
|
65
66
|
|
|
66
67
|
### Make a unique filename
|
|
67
68
|
|
|
68
69
|
```ts
|
|
69
|
-
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe";
|
|
70
|
+
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
|
|
70
71
|
import { randomUUID } from "node:crypto";
|
|
71
72
|
|
|
72
73
|
const base = sanitizeUntrustedFileName(req.body.fileName, "upload");
|
package/docs/index.md
CHANGED
|
@@ -69,6 +69,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
69
69
|
| [Permissions](permissions.md) | POSIX mode helpers plus Windows ACL inspection, raw owner/ACE facts, remediation, and private-directory creation. |
|
|
70
70
|
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
|
|
71
71
|
| [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
|
|
72
|
+
| [Public API inventory](public-api.md) | Complete runtime/type export cross-check against the generated declarations. |
|
|
72
73
|
| [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
|
|
73
74
|
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
|
74
75
|
| [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
|
package/docs/install-path.md
CHANGED
|
@@ -95,16 +95,19 @@ Returns a directory-safe segment **plus** a short content hash when sanitization
|
|
|
95
95
|
|
|
96
96
|
```ts
|
|
97
97
|
safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
|
|
98
|
-
safePathSegmentHashed("plugin/v1"); // "plugin-v1-
|
|
99
|
-
safePathSegmentHashed("plugin\\v1"); // "plugin-v1-
|
|
100
|
-
safePathSegmentHashed("Über@"); // "ber-
|
|
101
|
-
safePathSegmentHashed(""); // "skill"
|
|
102
|
-
safePathSegmentHashed("."); // "skill"
|
|
98
|
+
safePathSegmentHashed("plugin/v1"); // "plugin-v1-d9ef8af2eb"
|
|
99
|
+
safePathSegmentHashed("plugin\\v1"); // "plugin-v1-bed33f465b"
|
|
100
|
+
safePathSegmentHashed("Über@"); // "ber-e392bba2b3"
|
|
101
|
+
safePathSegmentHashed(""); // "skill-e3b0c44298"
|
|
102
|
+
safePathSegmentHashed("."); // "skill-cdb4ee2aea"
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
The sanitization is more aggressive than `safeDirName`: any character not in `[A-Za-z0-9._-]` becomes `-`, runs of `-` collapse, leading and trailing `-` are stripped, the empty/`.`/`..` fallback is `"skill"`. Long results are truncated to 50 chars before the hash is appended.
|
|
106
106
|
|
|
107
|
-
The
|
|
107
|
+
The suffix is the first 10 hex characters of `sha256(trimmedInput)`. It makes
|
|
108
|
+
collisions between distinct trimmed inputs unlikely, but it is a 40-bit
|
|
109
|
+
identifier rather than a mathematical uniqueness guarantee. Inputs that differ
|
|
110
|
+
only by surrounding whitespace intentionally map to the same output.
|
|
108
111
|
|
|
109
112
|
## Common patterns
|
|
110
113
|
|
package/docs/json.md
CHANGED
|
@@ -133,7 +133,12 @@ where lower latency matters more than crash-durability.
|
|
|
133
133
|
|
|
134
134
|
### `writeJsonSync(pathname, data)`
|
|
135
135
|
|
|
136
|
-
Synchronous variant.
|
|
136
|
+
Synchronous variant. It pretty-prints with two spaces, appends a newline,
|
|
137
|
+
creates parents at `0o700`, writes at `0o600`, and synchronizes the parent
|
|
138
|
+
directory best-effort. It has no options bag. On `EPERM`/`EEXIST`, its legacy
|
|
139
|
+
compatibility path may replace by copy rather than atomic rename; use the async
|
|
140
|
+
`writeJson()`/`replaceFileAtomic()` surfaces when fallback policy must be
|
|
141
|
+
explicit.
|
|
137
142
|
|
|
138
143
|
```ts
|
|
139
144
|
writeJsonSync("./prefs.json", { theme: "dark" });
|
|
@@ -187,7 +192,8 @@ const state = await readJsonIfExists<State>("./state.json");
|
|
|
187
192
|
|
|
188
193
|
| Throw / return | When |
|
|
189
194
|
|---|---|
|
|
190
|
-
| `null`
|
|
195
|
+
| `null` from `tryReadJson` / `tryReadJsonSync` | Any read or parse failure. |
|
|
196
|
+
| `null` from `readJsonIfExists` | File is missing; invalid or unreadable input still throws. |
|
|
191
197
|
| `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
|
|
192
198
|
| Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
|
|
193
199
|
|
package/docs/local-roots.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# Local roots
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- "Resolve this path string to an absolute path that lives inside one of the configured roots, or refuse it."
|
|
8
|
-
- "Read this path, where it could be `/abs/path`, `~/relative`, `file://…`, or simply the basename of a file in one of my roots."
|
|
3
|
+
The local-roots helpers accept a file path plus a list of trusted absolute
|
|
4
|
+
directories and return the first canonical root that contains the file. Use
|
|
5
|
+
them when configuration may name one of several approved media, cache, or
|
|
6
|
+
workspace roots.
|
|
9
7
|
|
|
10
8
|
```ts
|
|
11
9
|
import {
|
|
@@ -14,92 +12,102 @@ import {
|
|
|
14
12
|
} from "@openclaw/fs-safe/advanced";
|
|
15
13
|
```
|
|
16
14
|
|
|
17
|
-
##
|
|
15
|
+
## Input shape
|
|
18
16
|
|
|
19
|
-
Both helpers take
|
|
17
|
+
Both helpers take one options object. `filePath` may be absolute, home-relative,
|
|
18
|
+
relative to the current working directory, or a local `file://` URL. Relative
|
|
19
|
+
inputs are resolved exactly as Node resolves them; they are not searched as a
|
|
20
|
+
basename under each root.
|
|
20
21
|
|
|
21
22
|
```ts
|
|
22
23
|
type LocalRootsInputOptions = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
expandHome?: boolean; // expand ~ in inputs (default true)
|
|
24
|
+
filePath: string;
|
|
25
|
+
roots: readonly string[]; // trusted absolute paths, checked in order
|
|
26
|
+
label?: string; // used in validation errors
|
|
27
27
|
};
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
Roots may use `~` or local `file://` spellings, but each resolved root must be
|
|
31
|
+
absolute. Existing root symlinks are canonicalized before containment is
|
|
32
|
+
checked. Invalid root entries throw `FsSafeError("invalid-path")`; an invalid
|
|
33
|
+
`file://` input throws `Error`. A path that is valid but does not fall inside
|
|
34
|
+
any usable root returns `null`.
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
## `resolveLocalPathFromRootsSync(options)`
|
|
35
37
|
|
|
36
38
|
```ts
|
|
37
|
-
type
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
type ResolveLocalPathFromRootsSyncOptions = LocalRootsInputOptions & {
|
|
40
|
+
allowMissing?: boolean; // default false
|
|
41
|
+
requireFile?: boolean; // default false
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type LocalRootsPathResult = {
|
|
45
|
+
path: string; // canonical candidate path
|
|
46
|
+
root: string; // canonical containing root
|
|
47
|
+
};
|
|
40
48
|
```
|
|
41
49
|
|
|
50
|
+
For an existing upload:
|
|
51
|
+
|
|
42
52
|
```ts
|
|
43
53
|
import { resolveLocalPathFromRootsSync } from "@openclaw/fs-safe/advanced";
|
|
44
54
|
|
|
45
|
-
const r = resolveLocalPathFromRootsSync(
|
|
55
|
+
const r = resolveLocalPathFromRootsSync({
|
|
56
|
+
filePath: "/srv/uploads/photo.jpg",
|
|
46
57
|
roots: ["/srv/uploads", "/srv/cache"],
|
|
58
|
+
requireFile: true,
|
|
47
59
|
});
|
|
48
60
|
|
|
49
|
-
if (!r
|
|
50
|
-
console.log(r.
|
|
51
|
-
console.log(r.
|
|
52
|
-
console.log(r.relativePath); // photo.jpg
|
|
61
|
+
if (!r) throw new Error("photo is outside the configured roots");
|
|
62
|
+
console.log(r.path); // canonical path to photo.jpg
|
|
63
|
+
console.log(r.root); // canonical /srv/uploads
|
|
53
64
|
```
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
3. If the input is absolute (`/...` or Windows drive) and `allowAbsolute` is true, accept it as-is and check it falls under one of the roots.
|
|
62
|
-
4. Otherwise, treat the input as relative and resolve it against each root in order until one contains the resulting path.
|
|
63
|
-
|
|
64
|
-
If no root contains the result, returns `{ ok: false, reason: "outside-roots" }`.
|
|
65
|
-
|
|
66
|
-
`"invalid-input"` covers empty strings, embedded NULs, encoded `..` traversal, Windows network paths (`\\server\share`), and other constructs that should not be resolved at all.
|
|
66
|
+
By default the candidate must exist. `allowMissing: true` instead canonicalizes
|
|
67
|
+
the nearest existing ancestor and validates the missing tail, which is useful
|
|
68
|
+
when selecting a future output location. `requireFile: true` rejects existing
|
|
69
|
+
directories and other non-file leaves. Dangling symlinks and candidates whose
|
|
70
|
+
ancestors cannot be canonicalized are rejected rather than treated as safe
|
|
71
|
+
missing paths.
|
|
67
72
|
|
|
68
|
-
## `readLocalFileFromRoots(
|
|
73
|
+
## `readLocalFileFromRoots(options)`
|
|
69
74
|
|
|
70
|
-
|
|
75
|
+
The asynchronous helper opens the candidate through the matched [`Root`](root.md),
|
|
76
|
+
so no-follow, identity, hardlink, device-path, and byte-limit checks happen at
|
|
77
|
+
the read itself.
|
|
71
78
|
|
|
72
79
|
```ts
|
|
80
|
+
type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
|
|
81
|
+
hardlinks?: "reject" | "allow";
|
|
82
|
+
maxBytes?: number;
|
|
83
|
+
nonBlockingRead?: boolean;
|
|
84
|
+
symlinks?: "reject" | "follow-within-root";
|
|
85
|
+
};
|
|
86
|
+
|
|
73
87
|
type LocalRootsReadResult = ReadResult & {
|
|
74
|
-
|
|
75
|
-
relativePath: string;
|
|
88
|
+
root: string; // canonical containing root
|
|
76
89
|
};
|
|
90
|
+
```
|
|
77
91
|
|
|
78
|
-
|
|
92
|
+
```ts
|
|
93
|
+
const r = await readLocalFileFromRoots({
|
|
94
|
+
filePath: "/srv/uploads/photo.jpg",
|
|
79
95
|
roots: ["/srv/uploads", "/srv/cache"],
|
|
80
96
|
maxBytes: 8 * 1024 * 1024,
|
|
81
97
|
});
|
|
82
|
-
if (!r)
|
|
98
|
+
if (!r) throw new Error("photo is missing, unreadable, or outside the roots");
|
|
83
99
|
process.stdout.write(r.buffer);
|
|
84
100
|
```
|
|
85
101
|
|
|
86
|
-
The
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
|
|
92
|
-
hardlinks?: "reject" | "allow";
|
|
93
|
-
maxBytes?: number;
|
|
94
|
-
symlinks?: "reject" | "follow-within-root";
|
|
95
|
-
};
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
The read-side options are forwarded to `Root` for the actual read.
|
|
102
|
+
The helper returns `null` when no configured root can be opened or no safe read
|
|
103
|
+
succeeds. This intentionally collapses missing, outside-root, and per-root read
|
|
104
|
+
failures; use a single `Root` directly when the caller must distinguish those
|
|
105
|
+
outcomes. Omitting `maxBytes` preserves `Root`'s 16 MiB default.
|
|
99
106
|
|
|
100
|
-
##
|
|
107
|
+
## File URL and Windows-path companions
|
|
101
108
|
|
|
102
|
-
The
|
|
109
|
+
The advanced surface also exports the normalization helpers used around this
|
|
110
|
+
API:
|
|
103
111
|
|
|
104
112
|
```ts
|
|
105
113
|
import {
|
|
@@ -113,52 +121,20 @@ import {
|
|
|
113
121
|
} from "@openclaw/fs-safe/advanced";
|
|
114
122
|
```
|
|
115
123
|
|
|
116
|
-
- `safeFileURLToPath(fileUrl)`
|
|
117
|
-
|
|
118
|
-
- `
|
|
119
|
-
- `
|
|
120
|
-
|
|
121
|
-
- `
|
|
122
|
-
- `
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
### Multi-root config: search project, then user, then system
|
|
127
|
-
|
|
128
|
-
```ts
|
|
129
|
-
const text = await readLocalFileFromRoots(name, {
|
|
130
|
-
roots: [path.join(projectDir, "templates"), path.join(homedir(), ".app/templates"), "/etc/app/templates"],
|
|
131
|
-
allowAbsolute: false, // only resolve names, never absolute paths
|
|
132
|
-
maxBytes: 256 * 1024,
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### Validate a file:// URL at the API boundary
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
import { safeFileURLToPath, isWindowsNetworkPath } from "@openclaw/fs-safe/advanced";
|
|
140
|
-
|
|
141
|
-
let abs: string;
|
|
142
|
-
try {
|
|
143
|
-
abs = safeFileURLToPath(req.body.fileUrl);
|
|
144
|
-
} catch {
|
|
145
|
-
return reply(400, "invalid file URL");
|
|
146
|
-
}
|
|
147
|
-
if (isWindowsNetworkPath(abs)) return reply(400, "network paths not allowed");
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### Deny absolute, allow relative-only
|
|
151
|
-
|
|
152
|
-
```ts
|
|
153
|
-
const r = resolveLocalPathFromRootsSync(input, {
|
|
154
|
-
roots: ["/srv/workspace"],
|
|
155
|
-
allowAbsolute: false,
|
|
156
|
-
allowFileUrls: false,
|
|
157
|
-
});
|
|
158
|
-
```
|
|
124
|
+
- `safeFileURLToPath(fileUrl)` parses a local file URL and refuses remote hosts
|
|
125
|
+
or paths that decode to Windows network paths.
|
|
126
|
+
- `trySafeFileURLToPath(fileUrl)` returns `undefined` instead of throwing.
|
|
127
|
+
- `isWindowsDriveLetterPath()` and `isWindowsNetworkPath()` classify Windows
|
|
128
|
+
absolute and network spellings.
|
|
129
|
+
- `assertNoWindowsNetworkPath()` throws for a network path on Windows.
|
|
130
|
+
- `basenameFromMediaSource()` extracts a best-effort filename from a URL, data
|
|
131
|
+
URI, or path.
|
|
132
|
+
- `hasEncodedFileUrlSeparator()` detects percent-encoded slash or backslash
|
|
133
|
+
spellings.
|
|
159
134
|
|
|
160
135
|
## See also
|
|
161
136
|
|
|
162
|
-
- [`root()`](root.md) —
|
|
163
|
-
|
|
164
|
-
- [
|
|
137
|
+
- [`root()`](root.md) — use when one trusted root should preserve individual
|
|
138
|
+
failure codes.
|
|
139
|
+
- [Path helpers](path.md) — lexical and canonical containment primitives.
|
|
140
|
+
- [`pathScope()`](path-scope.md) — result-shaped single-root validation.
|