@openclaw/fs-safe 0.1.1 → 0.2.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 +58 -0
- package/README.md +58 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +25 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +168 -0
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +122 -125
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +484 -265
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -1
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +110 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +191 -0
- package/docs/config.md +94 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +77 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +204 -0
- package/docs/json.md +192 -0
- package/docs/local-roots.md +164 -0
- package/docs/output.md +92 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +230 -0
- package/docs/store.md +77 -0
- package/docs/temp.md +284 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +178 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +10 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- package/dist/sibling-temp-write.js +0 -40
package/docs/json.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# JSON files
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/json` is the standalone JSON surface: strict and lenient read variants plus atomic JSON writes.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
tryReadJson,
|
|
8
|
+
readJson,
|
|
9
|
+
readJsonIfExists,
|
|
10
|
+
readJsonSync,
|
|
11
|
+
tryReadJsonSync,
|
|
12
|
+
readRootJsonSync,
|
|
13
|
+
readRootJsonObjectSync,
|
|
14
|
+
readRootStructuredFileSync,
|
|
15
|
+
writeJson,
|
|
16
|
+
writeJsonSync,
|
|
17
|
+
JsonFileReadError,
|
|
18
|
+
} from "@openclaw/fs-safe/json";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Three reads, three failure shapes
|
|
22
|
+
|
|
23
|
+
Same input, three distinct contracts — pick the one whose error story matches your call site:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
await readJson<T>("./manifest.json"); // throws JsonFileReadError on missing or invalid
|
|
27
|
+
await readJsonIfExists<T>("./cache.json"); // returns null on missing; throws on invalid
|
|
28
|
+
await tryReadJson<T>("./optional.json"); // returns null on missing or invalid
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Helper | Missing file | Invalid JSON |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `readJson` | throws | throws |
|
|
34
|
+
| `readJsonIfExists` | `null` | throws |
|
|
35
|
+
| `tryReadJson` | `null` | `null` |
|
|
36
|
+
|
|
37
|
+
Use `readJson` when missing-or-malformed is a programmer error you want to surface immediately. Use `readJsonIfExists` when "file not there" is normal but malformed bytes should still page someone. Use `tryReadJson` when neither outcome should crash the caller.
|
|
38
|
+
|
|
39
|
+
`JsonFileReadError` carries `cause` so you can inspect whether the underlying failure was an `ENOENT`, a `SyntaxError`, or something else.
|
|
40
|
+
|
|
41
|
+
## Reading
|
|
42
|
+
|
|
43
|
+
### `readJson<T>(filePath)`
|
|
44
|
+
|
|
45
|
+
Async strict reader. Throws `JsonFileReadError` on missing or invalid input. The cast is unchecked — validate the shape with your own schema (zod, valibot, …) if it came from an untrusted source.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const manifest = await readJson<Manifest>("./manifest.json");
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `readJsonIfExists<T>(filePath)`
|
|
52
|
+
|
|
53
|
+
Async semi-lenient reader. Returns `null` if the file is missing; throws `JsonFileReadError` if the file exists but cannot be parsed.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const cache = (await readJsonIfExists<Cache>("./cache.json")) ?? freshCache();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `tryReadJson<T>(filePath)`
|
|
60
|
+
|
|
61
|
+
Async lenient reader. Returns `null` for any failure (missing, unreadable, invalid). The "no fuss" sibling.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const optional = (await tryReadJson<Settings>("./settings.json")) ?? defaults;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `readJsonSync<T>(filePath)`
|
|
68
|
+
|
|
69
|
+
Synchronous strict reader. Throws `JsonFileReadError` on missing or invalid input, matching the async `readJson` contract.
|
|
70
|
+
|
|
71
|
+
### `tryReadJsonSync<T>(pathname)`
|
|
72
|
+
|
|
73
|
+
Synchronous, generic, lenient. Returns `T | null`. Useful in boot paths where you want a typed result without async.
|
|
74
|
+
|
|
75
|
+
## Root-bounded structured reads
|
|
76
|
+
|
|
77
|
+
Use the root-bounded readers when you already have a trusted root directory and
|
|
78
|
+
a caller-controlled relative path, but you only need one synchronous structured
|
|
79
|
+
read instead of a full `root()` handle.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const result = readRootJsonObjectSync({
|
|
83
|
+
rootDir: "/safe/workspace",
|
|
84
|
+
relativePath: "plugin/openclaw.plugin.json",
|
|
85
|
+
boundaryLabel: "plugin manifest",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
// reason is "open", "parse", or "invalid"
|
|
90
|
+
throw new Error(result.reason);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(result.value);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`readRootJsonSync()` parses any JSON value. `readRootJsonObjectSync()` only
|
|
97
|
+
accepts objects. `readRootStructuredFileSync()` accepts a custom parser and
|
|
98
|
+
validator so callers can layer JSON5, TOML, YAML, or domain-specific validation
|
|
99
|
+
without making `fs-safe` depend on those formats.
|
|
100
|
+
|
|
101
|
+
## Writing
|
|
102
|
+
|
|
103
|
+
### `writeJson(filePath, value, options?)`
|
|
104
|
+
|
|
105
|
+
Async atomic JSON write. `JSON.stringify(value, null, 2)` + sibling-temp + rename. Defaults to file mode `0o600`.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
await writeJson("./state.json", state, { trailingNewline: true });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Options:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
type WriteJsonOptions = {
|
|
115
|
+
mode?: number; // file mode (default 0o600)
|
|
116
|
+
dirMode?: number; // mode for parent dirs created on demand
|
|
117
|
+
trailingNewline?: boolean; // append "\n" if missing (default false)
|
|
118
|
+
durable?: boolean; // default true; false skips temp/parent fsync
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`durable: false` preserves atomic temp-file replacement but skips the temp-file
|
|
123
|
+
and parent-directory `fsync` calls. Use it only for reconstructible JSON state
|
|
124
|
+
where lower latency matters more than crash-durability.
|
|
125
|
+
|
|
126
|
+
### `writeJsonSync(pathname, data)`
|
|
127
|
+
|
|
128
|
+
Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
writeJsonSync("./prefs.json", { theme: "dark" });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For atomic text writes, use [`writeTextAtomic`](atomic.md) from `@openclaw/fs-safe/atomic`. For in-process serialization, use `createAsyncLock` from the advanced surface, or prefer [`jsonStore`](json-store.md) when you want a JSON-specific read-modify-write helper.
|
|
135
|
+
|
|
136
|
+
## Common patterns
|
|
137
|
+
|
|
138
|
+
### Read-modify-write
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const state = (await readJsonIfExists<State>("./state.json")) ?? initialState();
|
|
142
|
+
state.lastRun = Date.now();
|
|
143
|
+
await writeJson("./state.json", state, { mode: 0o600, dirMode: 0o700 });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Atomic with secure mode
|
|
147
|
+
|
|
148
|
+
For credentials or other sensitive JSON, write at mode `0o600`:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
await writeJson("./auth.json", token, { mode: 0o600, dirMode: 0o700 });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For higher-assurance secrets, prefer the dedicated [secret-file helpers](secret-file.md) — they create the parent directory at `0o700` if missing.
|
|
155
|
+
|
|
156
|
+
### Strict load on boot
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
let manifest: Manifest;
|
|
160
|
+
try {
|
|
161
|
+
manifest = await readJson<Manifest>("./manifest.json");
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof JsonFileReadError) {
|
|
164
|
+
console.error("manifest unreadable:", err.cause);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Concurrent readers, single writer
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const state = await readJsonIfExists<State>("./state.json");
|
|
175
|
+
// missing returns null; malformed JSON still throws
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Error reference
|
|
179
|
+
|
|
180
|
+
| Throw / return | When |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `null` (lenient reads) | File missing or contents are not valid JSON. |
|
|
183
|
+
| `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
|
|
184
|
+
| Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
|
|
185
|
+
|
|
186
|
+
## See also
|
|
187
|
+
|
|
188
|
+
- [JSON store](json-store.md) — a single-file state wrapper with explicit per-call fallback (`readOr` / `updateOr`) and optional sidecar locking.
|
|
189
|
+
- [Atomic writes](atomic.md) — lower-level sibling-temp replacement helpers.
|
|
190
|
+
- [Secret files](secret-file.md) — JSON-or-text writes with mode 0600 in mode 0700 dirs.
|
|
191
|
+
- [Private file-store mode](private-file-store.md) — root-bounded JSON+text state stores.
|
|
192
|
+
- [File lock](sidecar-lock.md) — cross-process coordination.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Local roots
|
|
2
|
+
|
|
3
|
+
`local-roots` is a small set of helpers for code that holds a list of trusted base directories ("roots") and wants to look up an absolute path or a relative-to-some-root reference against any of them.
|
|
4
|
+
|
|
5
|
+
The shape covers two needs:
|
|
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."
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
readLocalFileFromRoots,
|
|
13
|
+
resolveLocalPathFromRootsSync,
|
|
14
|
+
} from "@openclaw/fs-safe/advanced";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Shape of a "roots input"
|
|
18
|
+
|
|
19
|
+
Both helpers take roots as either an array of strings or a `LocalRootsInputOptions` record. Each root is an absolute path the caller already trusts:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
type LocalRootsInputOptions = {
|
|
23
|
+
roots: string[]; // absolute paths
|
|
24
|
+
allowAbsolute?: boolean; // accept absolute inputs (default true)
|
|
25
|
+
allowFileUrls?: boolean; // accept file:// URLs (default true)
|
|
26
|
+
expandHome?: boolean; // expand ~ in inputs (default true)
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If a root is a symlink, it is canonicalized at lookup time. The helpers work in the order roots are listed: the first root that contains the resolved path wins.
|
|
31
|
+
|
|
32
|
+
## `resolveLocalPathFromRootsSync(input, options)`
|
|
33
|
+
|
|
34
|
+
Synchronous resolution. Returns:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
type LocalRootsPathResult =
|
|
38
|
+
| { ok: true; absolutePath: string; rootDir: string; relativePath: string }
|
|
39
|
+
| { ok: false; reason: "outside-roots" | "invalid-input" };
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { resolveLocalPathFromRootsSync } from "@openclaw/fs-safe/advanced";
|
|
44
|
+
|
|
45
|
+
const r = resolveLocalPathFromRootsSync("photo.jpg", {
|
|
46
|
+
roots: ["/srv/uploads", "/srv/cache"],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!r.ok) return reply(400, r.reason);
|
|
50
|
+
console.log(r.absolutePath); // /srv/uploads/photo.jpg (assuming it's there)
|
|
51
|
+
console.log(r.rootDir); // /srv/uploads
|
|
52
|
+
console.log(r.relativePath); // photo.jpg
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Resolution order
|
|
56
|
+
|
|
57
|
+
For each candidate input:
|
|
58
|
+
|
|
59
|
+
1. If the input is a `file://` URL and `allowFileUrls` is true, decode to an absolute path.
|
|
60
|
+
2. If the input begins with `~/` and `expandHome` is true, expand to the user's home dir.
|
|
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.
|
|
67
|
+
|
|
68
|
+
## `readLocalFileFromRoots(input, options)`
|
|
69
|
+
|
|
70
|
+
Async. Resolves through the same logic, then reads the file via [`Root`](root.md) so the read benefits from boundary checks, `O_NOFOLLOW`, and fd identity verification.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
type LocalRootsReadResult = ReadResult & {
|
|
74
|
+
rootDir: string;
|
|
75
|
+
relativePath: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const r = await readLocalFileFromRoots("photo.jpg", {
|
|
79
|
+
roots: ["/srv/uploads", "/srv/cache"],
|
|
80
|
+
maxBytes: 8 * 1024 * 1024,
|
|
81
|
+
});
|
|
82
|
+
if (!r) return reply(404);
|
|
83
|
+
process.stdout.write(r.buffer);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The result extends `ReadResult` (`{ buffer, realPath, stat }`) with the matched `rootDir` and the path relative to it. Returns `null` if the input doesn't resolve into any root or the file is missing.
|
|
87
|
+
|
|
88
|
+
### Read options
|
|
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.
|
|
99
|
+
|
|
100
|
+
## `local-file-access` companions
|
|
101
|
+
|
|
102
|
+
The `local-file-access` module (re-exported from `@openclaw/fs-safe/advanced`) supplies a few small helpers for input normalization that the roots helpers use under the hood. They are also useful on their own:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import {
|
|
106
|
+
assertNoWindowsNetworkPath,
|
|
107
|
+
basenameFromMediaSource,
|
|
108
|
+
hasEncodedFileUrlSeparator,
|
|
109
|
+
isWindowsDriveLetterPath,
|
|
110
|
+
isWindowsNetworkPath,
|
|
111
|
+
safeFileURLToPath,
|
|
112
|
+
trySafeFileURLToPath,
|
|
113
|
+
} from "@openclaw/fs-safe/advanced";
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- `safeFileURLToPath(fileUrl)` — `url.fileURLToPath` with explicit error throwing. Refuses URLs that decode to network paths.
|
|
117
|
+
- `trySafeFileURLToPath(fileUrl)` — same, returns `undefined` instead of throwing.
|
|
118
|
+
- `isWindowsDriveLetterPath(p, platform?)` — true for `C:\...` style absolute paths when the platform is Windows.
|
|
119
|
+
- `isWindowsNetworkPath(p, platform?)` — true for `\\server\share` and `//server/share` style paths when the platform is Windows.
|
|
120
|
+
- `assertNoWindowsNetworkPath(p, label?)` — throws if it is.
|
|
121
|
+
- `basenameFromMediaSource(source?)` — best-effort filename extraction from URLs / data URIs / paths, for naming downloaded media.
|
|
122
|
+
- `hasEncodedFileUrlSeparator(pathname)` — true for paths containing percent-encoded `/` (`%2F` / `%5C`), which often indicate traversal attempts.
|
|
123
|
+
|
|
124
|
+
## Common patterns
|
|
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
|
+
```
|
|
159
|
+
|
|
160
|
+
## See also
|
|
161
|
+
|
|
162
|
+
- [`root()`](root.md) — single-root variant of this multi-root setup.
|
|
163
|
+
- [Path helpers](path.md) — `isPathInside`, `safeRealpathSync` for ad-hoc checks.
|
|
164
|
+
- [`pathScope()`](path-scope.md) — single-root with `Result`-style returns.
|
package/docs/output.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# External outputs
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/output` covers the case where another library insists on
|
|
4
|
+
writing to an absolute path you give it. Browser downloads, renderers, media
|
|
5
|
+
tools, and native libraries often have this shape:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
|
|
9
|
+
|
|
10
|
+
await writeExternalFileWithinRoot({
|
|
11
|
+
rootDir: "/srv/workspace/downloads",
|
|
12
|
+
path: "reports/today.pdf",
|
|
13
|
+
write: async (filePath) => {
|
|
14
|
+
await download.saveAs(filePath);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The external writer never receives the final destination path. It receives a
|
|
20
|
+
private temp file path instead. After the callback returns, fs-safe copies that
|
|
21
|
+
staged file into the requested target through the same root boundary used by
|
|
22
|
+
`Root.copyIn()`.
|
|
23
|
+
|
|
24
|
+
## Signature
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
function writeExternalFileWithinRoot<T = void>(
|
|
28
|
+
options: ExternalFileWriteOptions<T>,
|
|
29
|
+
): Promise<ExternalFileWriteResult<T>>;
|
|
30
|
+
|
|
31
|
+
type ExternalFileWriteOptions<T = void> = {
|
|
32
|
+
rootDir: string;
|
|
33
|
+
path: string; // relative or absolute, but must stay under rootDir
|
|
34
|
+
write: (filePath: string) => Promise<T>;
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
mode?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type ExternalFileWriteResult<T = void> = {
|
|
40
|
+
path: string; // final absolute path under the canonical root
|
|
41
|
+
result: T; // value returned by write()
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The requested `path` must name a file. Missing destination parents are created
|
|
46
|
+
by the helper because the operation is "produce this output file under the
|
|
47
|
+
root"; callers should choose the filename before calling this API.
|
|
48
|
+
|
|
49
|
+
Use `maxBytes` when the external producer can create arbitrarily large files.
|
|
50
|
+
Use `mode` when the finalized file needs a specific POSIX mode. Both are
|
|
51
|
+
enforced during the `Root.copyIn()` finalization step, after the external writer
|
|
52
|
+
has produced the staged file and before the final target is committed.
|
|
53
|
+
|
|
54
|
+
## Why not pass the final path to the library?
|
|
55
|
+
|
|
56
|
+
If a target parent can be swapped after validation, handing an external library
|
|
57
|
+
the final path can make the library write outside the intended root before
|
|
58
|
+
fs-safe has a chance to finalize or reject the operation. This helper stages in
|
|
59
|
+
a private temp workspace first, then finalizes with `Root.copyIn()`. That keeps
|
|
60
|
+
the trust-boundary write inside fs-safe's root-aware copy/atomic-write path.
|
|
61
|
+
|
|
62
|
+
## Browser download example
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const outputPath = requestedOutputPath || sanitizeBrowserSuggestedName(suggestedFilename);
|
|
66
|
+
|
|
67
|
+
await writeExternalFileWithinRoot({
|
|
68
|
+
rootDir: downloadsRoot,
|
|
69
|
+
path: outputPath,
|
|
70
|
+
maxBytes: 512 * 1024 * 1024,
|
|
71
|
+
write: async (filePath) => {
|
|
72
|
+
await download.saveAs(filePath);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The chosen path may be absolute if it is already inside `downloadsRoot`, or
|
|
78
|
+
relative to `downloadsRoot`. Traversal, symlink parent escapes, hardlinked final
|
|
79
|
+
targets, over-large staged files, and missing temp files surface as
|
|
80
|
+
`FsSafeError`s.
|
|
81
|
+
|
|
82
|
+
This helper is not the right fit when the final filename depends on inspecting
|
|
83
|
+
the produced bytes. In that case, write to a private temp workspace, sniff or
|
|
84
|
+
validate the file, choose the final name, then copy or write into the root with
|
|
85
|
+
the normal root APIs.
|
|
86
|
+
|
|
87
|
+
## See also
|
|
88
|
+
|
|
89
|
+
- [Root writes](writing.md) — `write`, `copyIn`, `move`, and `mkdir`.
|
|
90
|
+
- [Temp workspaces](temp.md) — private scratch directories for longer workflows.
|
|
91
|
+
- [`pathScope()`](path-scope.md) — validation-only helper when you must pass an
|
|
92
|
+
absolute path directly to another library.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# pathScope()
|
|
2
|
+
|
|
3
|
+
`pathScope()` is an advanced helper with the same boundary semantics as `root()`, but it operates on **absolute paths** the caller already trusts and returns plain `{ ok, path }` results instead of throwing. Use it when you want the boundary check up front before handing an absolute path to another library.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { pathScope } from "@openclaw/fs-safe/advanced";
|
|
7
|
+
|
|
8
|
+
const uploads = pathScope("/srv/uploads", { label: "uploads directory" });
|
|
9
|
+
|
|
10
|
+
const photo = uploads.resolve("user/photo.jpg");
|
|
11
|
+
if (!photo.ok) throw new Error(photo.error);
|
|
12
|
+
|
|
13
|
+
await sharp(photo.path).resize(800).toFile(/* … */);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## When to reach for it
|
|
17
|
+
|
|
18
|
+
`root()` is the right answer when you want method-style I/O (`fs.write`, `fs.readText`). `pathScope()` is the right answer when:
|
|
19
|
+
|
|
20
|
+
- You're handing absolute paths to **other libraries** that take absolute path strings (Sharp, FFmpeg, `tar`, native modules) and just want the boundary check up front.
|
|
21
|
+
- You want to validate user-supplied paths in bulk and return a typed `Result` rather than catching exceptions.
|
|
22
|
+
- You want the scope's `label` to appear in the error message: `"Invalid path: must stay within uploads directory"`.
|
|
23
|
+
|
|
24
|
+
## Signature
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
function pathScope(rootDir: string, options: PathScopeOptions): PathScope;
|
|
28
|
+
|
|
29
|
+
type PathScopeOptions = {
|
|
30
|
+
label: string; // appears in error messages
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type PathScopeResolveOptions = {
|
|
34
|
+
defaultName?: string; // used when the requested path is empty
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type PathResult = { ok: true; path: string } | { ok: false; error: string };
|
|
38
|
+
type PathsResult = { ok: true; paths: string[] } | { ok: false; error: string };
|
|
39
|
+
|
|
40
|
+
type PathScope = {
|
|
41
|
+
rootDir: string;
|
|
42
|
+
label: string;
|
|
43
|
+
resolve(requestedPath: string, options?: PathScopeResolveOptions): PathResult;
|
|
44
|
+
resolveAll(requestedPaths: string[]): PathsResult;
|
|
45
|
+
existing(requestedPaths: string[]): Promise<PathsResult>;
|
|
46
|
+
files(requestedPaths: string[]): Promise<PathsResult>;
|
|
47
|
+
writable(requestedPath: string, options?: PathScopeResolveOptions): Promise<PathResult>;
|
|
48
|
+
ensureDir(requestedPath: string, options?: PathScopeResolveOptions & { mode?: number }): Promise<PathResult>;
|
|
49
|
+
};
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Methods
|
|
53
|
+
|
|
54
|
+
### `resolve(rel, options?)`
|
|
55
|
+
|
|
56
|
+
Synchronous, lexical only — no filesystem touch. Resolves the relative path against `rootDir` and verifies the result stays inside the scope. Use when you only need the path string:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const r = uploads.resolve("user/photo.jpg");
|
|
60
|
+
if (!r.ok) return reply(400, r.error);
|
|
61
|
+
console.log(r.path); // /srv/uploads/user/photo.jpg
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If the requested path is empty, `defaultName` (when set) is used.
|
|
65
|
+
|
|
66
|
+
### `resolveAll(rels)`
|
|
67
|
+
|
|
68
|
+
Synchronous bulk resolve. Returns `{ ok: true, paths }` if every input is in scope, or `{ ok: false, error }` on the first failure.
|
|
69
|
+
|
|
70
|
+
### `existing(rels)`
|
|
71
|
+
|
|
72
|
+
Async. Like `resolveAll`, but additionally allows trusted absolute paths whose `realpath` is inside the scope. Useful when callers pass either a relative path or an absolute path you've already validated.
|
|
73
|
+
|
|
74
|
+
### `files(rels)`
|
|
75
|
+
|
|
76
|
+
Async. Strict variant: every input must already exist as a regular file inside the scope, with `nlink === 1`. Symlinked entries are rejected.
|
|
77
|
+
|
|
78
|
+
### `writable(rel, options?)`
|
|
79
|
+
|
|
80
|
+
Async. Resolves a writable target: ensures the parent directory exists inside the scope, refuses targets whose existing inode is a symlink or hardlinked, and returns the absolute path to use for the write.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const t = await uploads.writable("reports/2026/05/report.pdf");
|
|
84
|
+
if (!t.ok) return reply(400, t.error);
|
|
85
|
+
await fs.writeFile(t.path, body);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `ensureDir(rel, options?)`
|
|
89
|
+
|
|
90
|
+
Async. `mkdir -p` inside the scope. Walks each segment, refuses any symlink in the path, creates missing directories. Optional `mode` sets the directory mode.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const dir = await uploads.ensureDir("inbox", { mode: 0o755 });
|
|
94
|
+
if (!dir.ok) return reply(500, dir.error);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Result type vs throwing
|
|
98
|
+
|
|
99
|
+
`pathScope` returns `{ ok: true, path }` / `{ ok: false, error }` instead of throwing. This makes it pleasant to use at validation boundaries:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
function handle(req: Req, res: Res) {
|
|
103
|
+
const r = uploads.resolve(req.body.path);
|
|
104
|
+
if (!r.ok) return res.status(400).json({ error: r.error });
|
|
105
|
+
return doWork(r.path);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For exception-flavored APIs, use `root()` instead — it throws `FsSafeError` with a typed `code`.
|
|
110
|
+
|
|
111
|
+
## Combine with `root()`
|
|
112
|
+
|
|
113
|
+
A common pattern at the edge of an HTTP handler: validate the input with `pathScope`, do the I/O with `root()`:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const uploads = pathScope("/srv/uploads", { label: "uploads" });
|
|
117
|
+
const fs = await root("/srv/uploads", { hardlinks: "reject" });
|
|
118
|
+
|
|
119
|
+
const r = uploads.resolve(req.body.path);
|
|
120
|
+
if (!r.ok) return reply(400, r.error);
|
|
121
|
+
const rel = path.relative(fs.rootReal, r.path);
|
|
122
|
+
|
|
123
|
+
const text = await fs.readText(rel);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## See also
|
|
127
|
+
|
|
128
|
+
- [`root()`](root.md) — method-style boundary that throws on failure.
|
|
129
|
+
- [Path helpers](path.md) — `isPathInside`, `safeRealpathSync`, `isWithinDir` for ad-hoc checks.
|
|
130
|
+
- [Errors](errors.md) — error semantics (note: `pathScope` returns errors, doesn't throw).
|