@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/reading.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Reading
|
|
2
|
+
|
|
3
|
+
The `Root` handle exposes five read shapes. Pick the narrowest one that gives you what you need — narrower shapes do less work and surface fewer footguns.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const result = await fs.read("notes/today.txt"); // { buffer, realPath, stat }
|
|
7
|
+
const text = await fs.readText("notes/today.txt"); // string
|
|
8
|
+
const bytes = await fs.readBytes("image.png"); // Buffer
|
|
9
|
+
const json = await fs.readJson<Config>("config.json"); // T
|
|
10
|
+
const opened = await fs.open("large.log"); // FileHandle for streaming
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What every read does
|
|
14
|
+
|
|
15
|
+
Regardless of shape, every read goes through the same boundary checks:
|
|
16
|
+
|
|
17
|
+
1. Resolve the relative path against the canonical real root.
|
|
18
|
+
2. Reject anything that escapes the root (`outside-workspace`).
|
|
19
|
+
3. Reject `..` segments and absolute inputs (unless via `readAbsolute` with an in-root absolute path).
|
|
20
|
+
4. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
|
|
21
|
+
5. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
|
|
22
|
+
6. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
|
|
23
|
+
7. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
|
|
24
|
+
|
|
25
|
+
## Read shapes
|
|
26
|
+
|
|
27
|
+
### `fs.read(rel, options?)`
|
|
28
|
+
|
|
29
|
+
The full result. Use it when you need both the bytes and the verified `realPath` or `stat`:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const { buffer, realPath, stat } = await fs.read("notes/today.txt");
|
|
33
|
+
console.log(`${stat.size} bytes at ${realPath}`);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `fs.readText(rel, options?)`
|
|
37
|
+
|
|
38
|
+
`buffer.toString(encoding)`. Defaults to `defaults.encoding ?? "utf8"`. Pass `encoding` per call to override:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const utf16 = await fs.readText("doc.txt", { encoding: "utf16le" });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `fs.readBytes(rel, options?)`
|
|
45
|
+
|
|
46
|
+
The buffer alone. Useful when you don't care about the realPath or stat:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const png = await fs.readBytes("image.png");
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### `fs.readJson<T>(rel, options?)`
|
|
53
|
+
|
|
54
|
+
`readText` + `JSON.parse`. The generic is a *cast*, not a validator — validate the parsed value at your application boundary if it came from a less-trusted source.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
type Config = { tokens: string[] };
|
|
58
|
+
const config = await fs.readJson<Config>("config.json");
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
For tighter control over malformed-or-missing JSON, use the standalone helpers in [`@openclaw/fs-safe/json`](json.md): `tryReadJson` (returns `null` on missing/invalid) vs `readJson` (throws).
|
|
62
|
+
|
|
63
|
+
### `fs.open(rel, options?)`
|
|
64
|
+
|
|
65
|
+
Returns a `FileHandle` plus the verified `realPath` and `stat`. Use this for streaming or partial reads, and **always close the handle**:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const opened = await fs.open("large.log");
|
|
69
|
+
try {
|
|
70
|
+
const stream = opened.handle.createReadStream();
|
|
71
|
+
for await (const chunk of stream) {
|
|
72
|
+
process.stdout.write(chunk);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
await opened.handle.close();
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Read options
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
type RootReadOptions = {
|
|
83
|
+
hardlinks?: "reject" | "allow"; // override defaults.hardlinks
|
|
84
|
+
maxBytes?: number; // refuse reads larger than this many bytes
|
|
85
|
+
nonBlockingRead?: boolean; // schedule the read off the main loop
|
|
86
|
+
symlinks?: "reject" | "follow-within-root"; // override defaults.symlinks
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`maxBytes` is enforced eagerly: the library reads up to `maxBytes + 1` and throws `too-large` if there is more, so a hostile target cannot silently exhaust memory.
|
|
91
|
+
|
|
92
|
+
`nonBlockingRead` is a scheduling hint. It does not affect safety — it lets you keep the event loop responsive when reading large files.
|
|
93
|
+
|
|
94
|
+
## `readAbsolute()` and `reader()`
|
|
95
|
+
|
|
96
|
+
Some APIs hand you an absolute path that the caller has already produced. Going back to a relative form just to call `read()` is awkward, so the library exposes:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
fs.readAbsolute(absPath, options?) // ReadResult, abs path must be inside the root
|
|
100
|
+
fs.reader(options?) // (path) => Promise<Buffer>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`readAbsolute` accepts absolute paths. Anything outside the root throws `outside-workspace`.
|
|
104
|
+
|
|
105
|
+
`reader()` returns a closure that takes either a relative or an absolute path and returns a Buffer. Useful for plugging `fs-safe` into framework loader hooks:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const load = fs.reader({ maxBytes: 4 * 1024 * 1024 });
|
|
109
|
+
await someLibrary.parseTemplate({ load });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Inspection vs reading
|
|
113
|
+
|
|
114
|
+
`fs.exists`, `fs.stat`, and `fs.list` are advisory. They are safe to call to drive UI or decisions, but they do **not** pin the file:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
if (await fs.exists("notes/today.txt")) {
|
|
118
|
+
// the file existed when stat() ran — it may not now
|
|
119
|
+
const text = await fs.readText("notes/today.txt"); // this is the call that pins
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A symlink swap between `exists` and `readText` is caught by the read; the boundary is per-call.
|
|
124
|
+
|
|
125
|
+
## Streaming patterns
|
|
126
|
+
|
|
127
|
+
### Read into a writable stream
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { pipeline } from "node:stream/promises";
|
|
131
|
+
|
|
132
|
+
const opened = await fs.open("large.log");
|
|
133
|
+
try {
|
|
134
|
+
await pipeline(opened.handle.createReadStream(), process.stdout);
|
|
135
|
+
} finally {
|
|
136
|
+
await opened.handle.close();
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Read in chunks
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const opened = await fs.open("large.bin");
|
|
144
|
+
try {
|
|
145
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
146
|
+
let off = 0;
|
|
147
|
+
while (true) {
|
|
148
|
+
const { bytesRead } = await opened.handle.read(buf, 0, buf.length, off);
|
|
149
|
+
if (bytesRead === 0) break;
|
|
150
|
+
consume(buf.subarray(0, bytesRead));
|
|
151
|
+
off += bytesRead;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
await opened.handle.close();
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Common errors
|
|
159
|
+
|
|
160
|
+
- **`outside-workspace`** — relative path escaped the root, or `readAbsolute` got an absolute path outside.
|
|
161
|
+
- **`not-found`** — the file is gone.
|
|
162
|
+
- **`not-file`** — you read a directory or a non-regular file (FIFO, socket, …).
|
|
163
|
+
- **`symlink`** — a path component is a symlink and the policy is `reject`.
|
|
164
|
+
- **`path-mismatch`** — opened fd identity did not match the resolved path. Almost always a TOCTOU swap by something else.
|
|
165
|
+
- **`hardlink`** — `hardlinks: "reject"` saw `nlink > 1`.
|
|
166
|
+
- **`too-large`** — read exceeded `maxBytes`.
|
|
167
|
+
|
|
168
|
+
See [Errors](errors.md) for the full list.
|
|
169
|
+
|
|
170
|
+
## See also
|
|
171
|
+
|
|
172
|
+
- [Writing](writing.md) — companion verbs for produce-side I/O.
|
|
173
|
+
- [JSON files](json.md) — standalone strict/lenient JSON helpers.
|
|
174
|
+
- [Secure file reads](secure-file.md) — pinned absolute file reads with permission checks.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Regular file helpers
|
|
2
|
+
|
|
3
|
+
The advanced `regular-file` helpers provide direct read/append/stat helpers for absolute file paths, with an explicit "regular file or nothing" contract. Useful when you have a trusted absolute path and want a thin layer on top of `fs` that:
|
|
4
|
+
|
|
5
|
+
- refuses non-regular files (directories, FIFOs, sockets, symlinks)
|
|
6
|
+
- enforces a `maxBytes` read cap
|
|
7
|
+
- separates "missing" from "io-error" in the result type
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import {
|
|
11
|
+
readRegularFile,
|
|
12
|
+
readRegularFileSync,
|
|
13
|
+
appendRegularFile,
|
|
14
|
+
appendRegularFileSync,
|
|
15
|
+
resolveRegularFileAppendFlags,
|
|
16
|
+
statRegularFile,
|
|
17
|
+
statRegularFileSync,
|
|
18
|
+
type AppendRegularFileOptions,
|
|
19
|
+
type RegularFileStatResult,
|
|
20
|
+
} from "@openclaw/fs-safe/advanced";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Stat
|
|
24
|
+
|
|
25
|
+
### `statRegularFile(filePath)`
|
|
26
|
+
|
|
27
|
+
Async. Returns:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
type RegularFileStatResult =
|
|
31
|
+
| { missing: true }
|
|
32
|
+
| { missing: false; stat: Stats };
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A non-regular file (directory, FIFO, …) returns `{ missing: false }` with a `stat` whose `isFile()` is false — the helper does not throw, you decide what to do.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { statRegularFile } from "@openclaw/fs-safe/advanced";
|
|
39
|
+
|
|
40
|
+
const r = await statRegularFile("/var/log/app.log");
|
|
41
|
+
if (r.missing) return;
|
|
42
|
+
if (!r.stat.isFile()) throw new Error("expected a regular file");
|
|
43
|
+
console.log(`size=${r.stat.size}`);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### `statRegularFileSync(filePath)`
|
|
47
|
+
|
|
48
|
+
Synchronous variant. Same shape.
|
|
49
|
+
|
|
50
|
+
## Read
|
|
51
|
+
|
|
52
|
+
### `readRegularFile(params)`
|
|
53
|
+
|
|
54
|
+
Async. Reads the entire file into a Buffer if it is a regular file, with `maxBytes` enforcement.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { readRegularFile } from "@openclaw/fs-safe/advanced";
|
|
58
|
+
|
|
59
|
+
const result = await readRegularFile({
|
|
60
|
+
filePath: "/var/log/app.log",
|
|
61
|
+
maxBytes: 4 * 1024 * 1024,
|
|
62
|
+
});
|
|
63
|
+
if (result.missing) return null;
|
|
64
|
+
if (!result.regular) throw new Error("not a regular file");
|
|
65
|
+
processLog(result.buffer);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Result shape:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
type Result =
|
|
72
|
+
| { missing: true }
|
|
73
|
+
| { missing: false; regular: false; stat: Stats }
|
|
74
|
+
| { missing: false; regular: true; stat: Stats; buffer: Buffer };
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Throws `FsSafeError` with code `too-large` if the file exceeds `maxBytes`. Other I/O errors propagate as `NodeJS.ErrnoException`.
|
|
78
|
+
|
|
79
|
+
### `readRegularFileSync(params)`
|
|
80
|
+
|
|
81
|
+
Synchronous variant. Same shape; the only required field is `filePath`. `maxBytes` is optional.
|
|
82
|
+
|
|
83
|
+
## Append
|
|
84
|
+
|
|
85
|
+
### `appendRegularFile(options)`
|
|
86
|
+
|
|
87
|
+
Async. Opens the file in append mode, writes data, closes. Refuses non-regular targets:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { appendRegularFile } from "@openclaw/fs-safe/advanced";
|
|
91
|
+
|
|
92
|
+
await appendRegularFile({
|
|
93
|
+
filePath: "/var/log/app.log",
|
|
94
|
+
data: `[${new Date().toISOString()}] ${line}\n`,
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
prependNewlineIfNeeded: true,
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Options
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
type AppendRegularFileOptions = {
|
|
104
|
+
filePath: string;
|
|
105
|
+
data: string | Buffer;
|
|
106
|
+
encoding?: BufferEncoding; // default utf8 when data is string
|
|
107
|
+
prependNewlineIfNeeded?: boolean; // insert "\n" if file does not end with one
|
|
108
|
+
flags?: number; // raw open flags; default O_WRONLY | O_APPEND
|
|
109
|
+
mode?: number; // default 0o644 if file is created
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`prependNewlineIfNeeded` reads the trailing byte of the existing file and prepends a `\n` to your data if it isn't already present. Useful for log appenders that want to preserve line boundaries even when callers forget the newline.
|
|
114
|
+
|
|
115
|
+
### `appendRegularFileSync(options)`
|
|
116
|
+
|
|
117
|
+
Synchronous. Same options.
|
|
118
|
+
|
|
119
|
+
### `resolveRegularFileAppendFlags(append, truncateExisting)`
|
|
120
|
+
|
|
121
|
+
Helper that returns the right open-flag bitmask for combinations of "append" / "truncate". Use it when you're building your own open path and want to match the append helpers' behavior:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { resolveRegularFileAppendFlags } from "@openclaw/fs-safe/advanced";
|
|
125
|
+
|
|
126
|
+
const flags = resolveRegularFileAppendFlags(true, false); // O_WRONLY | O_APPEND | O_CREAT
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Difference from `Root` methods
|
|
130
|
+
|
|
131
|
+
| `regular-file` | `Root` |
|
|
132
|
+
|---|---|
|
|
133
|
+
| Absolute paths only. | Relative to the root. |
|
|
134
|
+
| No identity check post-open. | Identity check on every read/write. |
|
|
135
|
+
| Caller must be confident the path is trusted. | Boundary check is automatic. |
|
|
136
|
+
| Returns explicit `{missing, regular}` shape. | Throws `FsSafeError` with `code`. |
|
|
137
|
+
|
|
138
|
+
If your call site already trusts the path (it came from your own config, not a caller), `regular-file` is a thinner, faster surface. If the path is caller-influenced, prefer `root()` or wrap in [`pathScope()`](path-scope.md).
|
|
139
|
+
|
|
140
|
+
## Common patterns
|
|
141
|
+
|
|
142
|
+
### Read a config file if it's there, else seed
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const r = await readRegularFile({ filePath: "/etc/app/config.json", maxBytes: 64 * 1024 });
|
|
146
|
+
if (r.missing) {
|
|
147
|
+
await writeJson("/etc/app/config.json", defaultConfig);
|
|
148
|
+
} else if (r.regular) {
|
|
149
|
+
applyConfig(JSON.parse(r.buffer.toString("utf8")));
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error("/etc/app/config.json is not a regular file");
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Cheap "exists and is a file" check
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const r = await statRegularFile(p);
|
|
159
|
+
if (r.missing || !r.stat.isFile()) return false;
|
|
160
|
+
return true;
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Bounded log tail
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const r = await readRegularFile({ filePath: logPath, maxBytes: 1 * 1024 * 1024 });
|
|
167
|
+
if (r.missing || !r.regular) return [];
|
|
168
|
+
return r.buffer.toString("utf8").split("\n").slice(-100);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## See also
|
|
172
|
+
|
|
173
|
+
- [Reading](reading.md) — `Root` reads with boundary checks.
|
|
174
|
+
- [Atomic writes](atomic.md) — for atomic write semantics, prefer `replaceFileAtomic`.
|
|
175
|
+
- [`fs.appendFile`](https://nodejs.org/api/fs.html#fsappendfilepath-data-options-callback) — Node's stock append, without regular-file gating.
|
package/docs/root.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# root()
|
|
2
|
+
|
|
3
|
+
`root()` is the primary entry point. It takes a trusted directory and returns a capability-style `Root` handle whose methods accept relative paths and refuse to escape the directory.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { root } from "@openclaw/fs-safe";
|
|
7
|
+
|
|
8
|
+
const fs = await root("/srv/workspace", {
|
|
9
|
+
hardlinks: "reject",
|
|
10
|
+
symlinks: "reject",
|
|
11
|
+
mkdir: true,
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Signature
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
19
|
+
|
|
20
|
+
type RootDefaults = {
|
|
21
|
+
hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
|
|
22
|
+
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
23
|
+
mkdir?: boolean; // create missing parent dirs on write/openWritable/append
|
|
24
|
+
mode?: number; // file mode applied to new writes; per-call override available
|
|
25
|
+
nonBlockingRead?: boolean; // schedule reads on a worker; useful for large files
|
|
26
|
+
symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
|
|
31
|
+
|
|
32
|
+
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.
|
|
33
|
+
|
|
34
|
+
## The `Root` interface
|
|
35
|
+
|
|
36
|
+
Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.
|
|
37
|
+
|
|
38
|
+
### Reads
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
fs.read(rel, options?) // { buffer, realPath, stat }
|
|
42
|
+
fs.readBytes(rel, options?) // Buffer
|
|
43
|
+
fs.readText(rel, options?) // string
|
|
44
|
+
fs.readJson<T>(rel, options?) // parsed T
|
|
45
|
+
fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] }
|
|
46
|
+
fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
|
|
47
|
+
fs.reader(options?) // (path) => Promise<Buffer>; useful for loader APIs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`open()` returns a Node `FileHandle` for streaming. Prefer `await using` for cleanup:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
await using opened = await fs.open("large.log");
|
|
54
|
+
{
|
|
55
|
+
for await (const chunk of opened.handle.createReadStream()) {
|
|
56
|
+
process.stdout.write(chunk);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Writes
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
fs.write(rel, data, options?) // overwrite-ok atomic write
|
|
65
|
+
fs.create(rel, data, options?) // throws "already-exists" if target exists
|
|
66
|
+
fs.writeJson(rel, value, options?) // JSON.stringify + atomic write
|
|
67
|
+
fs.createJson(rel, value, options?) // create() variant of writeJson
|
|
68
|
+
fs.append(rel, data, options?) // append text/buffer; respects mkdir default
|
|
69
|
+
fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
|
|
70
|
+
fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
|
|
71
|
+
fs.move(from, to, options?) // rename within the root; defaults to no clobber
|
|
72
|
+
fs.remove(rel) // unlink file or rmdir empty directory
|
|
73
|
+
fs.mkdir(rel) // mkdir -p (creates missing parents)
|
|
74
|
+
fs.ensureRoot() // accepts "" / "." as the root itself
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
78
|
+
|
|
79
|
+
`copyIn` is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects `maxBytes`.
|
|
80
|
+
|
|
81
|
+
`openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
|
|
82
|
+
|
|
83
|
+
### Inspection (advisory)
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
fs.exists(rel) // boolean
|
|
87
|
+
fs.stat(rel) // PathStat
|
|
88
|
+
fs.list(rel) // string[]
|
|
89
|
+
fs.list(rel, { withFileTypes }) // DirEntry[]
|
|
90
|
+
fs.resolve(rel) // absolute path inside the root, after canonicalization
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.
|
|
94
|
+
|
|
95
|
+
## Python helper mode
|
|
96
|
+
|
|
97
|
+
On POSIX, mutation and inspection methods that need fd-relative directory
|
|
98
|
+
operations go through one persistent Python helper process. This avoids a
|
|
99
|
+
spawn-per-call cost while still using `openat`/`renameat`/`unlinkat`-style
|
|
100
|
+
operations that Node's `fs` API does not expose ergonomically.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { configureFsSafePython } from "@openclaw/fs-safe/config";
|
|
104
|
+
|
|
105
|
+
configureFsSafePython({ mode: "off" }); // Node-only fallback path
|
|
106
|
+
configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`auto` is the default. Configure the mode before creating roots. Without the
|
|
110
|
+
helper, root methods still run, but same-UID races that swap parent directories
|
|
111
|
+
between validation and mutation are harder to close completely. Use `require`
|
|
112
|
+
when that downgrade should be treated as a deployment failure. See
|
|
113
|
+
[Python helper policy](python-helper.md) for deployment guidance.
|
|
114
|
+
|
|
115
|
+
### Properties
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
fs.rootDir // the directory you passed in
|
|
119
|
+
fs.rootReal // its canonical real path (after symlink resolution)
|
|
120
|
+
fs.rootWithSep // rootReal with a trailing separator, for prefix comparisons
|
|
121
|
+
fs.defaults // the RootDefaults you passed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Failure semantics
|
|
125
|
+
|
|
126
|
+
Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not message text. Common codes:
|
|
127
|
+
|
|
128
|
+
| Code | When it fires |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `invalid-path` | The input path is malformed, including embedded NUL bytes. |
|
|
131
|
+
| `outside-workspace` | The input resolves outside the root, or contains a `..` segment that would escape it. |
|
|
132
|
+
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
|
|
133
|
+
| `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
|
|
134
|
+
| `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
|
|
135
|
+
| `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
|
|
136
|
+
| `hardlink` | The target's `nlink > 1` and `hardlinks` policy is `reject`. |
|
|
137
|
+
| `path-mismatch` | Post-open identity check failed — the opened fd does not match the resolved path. |
|
|
138
|
+
| `too-large` | Read exceeded `maxBytes`. |
|
|
139
|
+
|
|
140
|
+
Full list in the [Errors](errors.md) reference.
|
|
141
|
+
|
|
142
|
+
## Defaults vs per-call options
|
|
143
|
+
|
|
144
|
+
Defaults reduce repetition; per-call options handle exceptions:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const fs = await root("/srv/workspace", {
|
|
148
|
+
symlinks: "reject",
|
|
149
|
+
hardlinks: "reject",
|
|
150
|
+
mkdir: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Default: symlinks rejected.
|
|
154
|
+
await fs.readText("config.toml");
|
|
155
|
+
|
|
156
|
+
// One specific path needs to follow a symlink that lands inside the root.
|
|
157
|
+
await fs.readText("links/current.log", { symlinks: "follow-within-root" });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Text helpers default to UTF-8. Pass `encoding` per call to `readText`, `readJson`, `write`, `create`, or `append` when you need another encoding.
|
|
161
|
+
|
|
162
|
+
## Common patterns
|
|
163
|
+
|
|
164
|
+
### Read-only loader
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const fs = await root("/srv/workspace", { symlinks: "reject", hardlinks: "reject" });
|
|
168
|
+
const load = fs.reader();
|
|
169
|
+
const a = await load("notes/today.txt"); // relative
|
|
170
|
+
const b = await load("/srv/workspace/state.bin"); // absolute, but inside the root
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`fs.reader()` returns a `(path) => Promise<Buffer>` callback. Useful when wiring `fs-safe` into APIs that accept a generic loader function. Absolute paths outside the root are rejected with `outside-workspace`.
|
|
174
|
+
|
|
175
|
+
### "Touch only if missing" seeding
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
try {
|
|
179
|
+
await fs.create("config/seed.json", initialJson);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof FsSafeError && err.code === "already-exists") {
|
|
182
|
+
// existing config wins
|
|
183
|
+
} else {
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Replace + verify
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
await fs.write("state.json", JSON.stringify(state, null, 2));
|
|
193
|
+
const echoed = await fs.readJson<State>("state.json");
|
|
194
|
+
assertDeepEqual(echoed, state);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`write` is atomic, so the file is either old or new — never half-written. Re-reading lets you detect a parallel writer, if one exists.
|
|
198
|
+
|
|
199
|
+
## See also
|
|
200
|
+
|
|
201
|
+
- [Reading](reading.md) — read variants in depth, plus stream patterns.
|
|
202
|
+
- [Writing](writing.md) — write/create/move/remove in depth.
|
|
203
|
+
- [pathScope()](path-scope.md) — the same boundary semantics over an absolute path you already trust.
|
|
204
|
+
- [Atomic writes](atomic.md) — the lower-level helpers used by `fs.write`.
|
|
205
|
+
- [Errors](errors.md) — the closed code union you'll be catching.
|