@openclaw/fs-safe 0.1.0 → 0.1.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +23 -3
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +51 -7
  5. package/dist/archive.js +2 -2
  6. package/dist/file-store-boundary.d.ts +27 -0
  7. package/dist/file-store-boundary.d.ts.map +1 -0
  8. package/dist/file-store-boundary.js +164 -0
  9. package/dist/file-store.d.ts.map +1 -1
  10. package/dist/file-store.js +62 -72
  11. package/dist/path.d.ts +1 -0
  12. package/dist/path.d.ts.map +1 -1
  13. package/dist/path.js +6 -3
  14. package/dist/private-file-store.d.ts +5 -7
  15. package/dist/private-file-store.d.ts.map +1 -1
  16. package/dist/private-file-store.js +21 -34
  17. package/dist/root-context.d.ts +23 -0
  18. package/dist/root-context.d.ts.map +1 -0
  19. package/dist/root-context.js +62 -0
  20. package/dist/root-errors.d.ts +4 -0
  21. package/dist/root-errors.d.ts.map +1 -0
  22. package/dist/root-errors.js +21 -0
  23. package/dist/root-impl.d.ts +108 -0
  24. package/dist/root-impl.d.ts.map +1 -0
  25. package/dist/{safe-root.js → root-impl.js} +464 -251
  26. package/dist/root.d.ts +1 -107
  27. package/dist/root.d.ts.map +1 -1
  28. package/dist/root.js +1 -1332
  29. package/docs/advanced.md +100 -0
  30. package/docs/archive.md +199 -0
  31. package/docs/atomic.md +163 -0
  32. package/docs/config.md +65 -0
  33. package/docs/contributing.md +80 -0
  34. package/docs/errors.md +154 -0
  35. package/docs/file-store.md +213 -0
  36. package/docs/filename.md +0 -0
  37. package/docs/index.md +76 -0
  38. package/docs/install-path.md +167 -0
  39. package/docs/install.md +150 -0
  40. package/docs/json-store.md +200 -0
  41. package/docs/json.md +158 -0
  42. package/docs/local-roots.md +164 -0
  43. package/docs/path-scope.md +130 -0
  44. package/docs/path.md +191 -0
  45. package/docs/permissions.md +95 -0
  46. package/docs/private-file-store.md +45 -0
  47. package/docs/python-helper.md +103 -0
  48. package/docs/quickstart.md +147 -0
  49. package/docs/reading.md +174 -0
  50. package/docs/regular-file.md +175 -0
  51. package/docs/root.md +205 -0
  52. package/docs/secret-file.md +154 -0
  53. package/docs/secure-file.md +82 -0
  54. package/docs/security-model.md +99 -0
  55. package/docs/sidecar-lock.md +229 -0
  56. package/docs/store.md +44 -0
  57. package/docs/temp.md +276 -0
  58. package/docs/test-hooks.md +74 -0
  59. package/docs/testing.md +168 -0
  60. package/docs/timing.md +121 -0
  61. package/docs/types.md +155 -0
  62. package/docs/walk.md +63 -0
  63. package/docs/writing.md +222 -0
  64. package/package.json +5 -2
  65. package/dist/archive-utils.d.ts +0 -3
  66. package/dist/archive-utils.d.ts.map +0 -1
  67. package/dist/archive-utils.js +0 -26
  68. package/dist/boundary-file-read.d.ts +0 -44
  69. package/dist/boundary-file-read.d.ts.map +0 -1
  70. package/dist/boundary-file-read.js +0 -129
  71. package/dist/boundary-path.d.ts +0 -39
  72. package/dist/boundary-path.d.ts.map +0 -1
  73. package/dist/boundary-path.js +0 -598
  74. package/dist/fs-pinned-path-helper.d.ts +0 -7
  75. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  76. package/dist/fs-pinned-path-helper.js +0 -182
  77. package/dist/fs-pinned-write-helper.d.ts +0 -21
  78. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  79. package/dist/fs-pinned-write-helper.js +0 -263
  80. package/dist/hardlink-guards.d.ts +0 -7
  81. package/dist/hardlink-guards.d.ts.map +0 -1
  82. package/dist/hardlink-guards.js +0 -30
  83. package/dist/install-safe-path.d.ts +0 -20
  84. package/dist/install-safe-path.d.ts.map +0 -1
  85. package/dist/install-safe-path.js +0 -94
  86. package/dist/json-file.d.ts +0 -3
  87. package/dist/json-file.d.ts.map +0 -1
  88. package/dist/json-file.js +0 -123
  89. package/dist/json-files.d.ts +0 -20
  90. package/dist/json-files.d.ts.map +0 -1
  91. package/dist/json-files.js +0 -153
  92. package/dist/path-alias-guards.d.ts +0 -19
  93. package/dist/path-alias-guards.d.ts.map +0 -1
  94. package/dist/path-alias-guards.js +0 -21
  95. package/dist/path-guards.d.ts +0 -7
  96. package/dist/path-guards.d.ts.map +0 -1
  97. package/dist/path-guards.js +0 -49
  98. package/dist/path-safety.d.ts +0 -12
  99. package/dist/path-safety.d.ts.map +0 -1
  100. package/dist/path-safety.js +0 -50
  101. package/dist/safe-open-sync.d.ts +0 -24
  102. package/dist/safe-open-sync.d.ts.map +0 -1
  103. package/dist/safe-open-sync.js +0 -71
  104. package/dist/safe-root.d.ts +0 -123
  105. package/dist/safe-root.d.ts.map +0 -1
  106. package/dist/secure-temp-workspace.d.ts +0 -25
  107. package/dist/secure-temp-workspace.d.ts.map +0 -1
  108. package/dist/secure-temp-workspace.js +0 -136
  109. package/dist/sibling-temp-file.d.ts +0 -16
  110. package/dist/sibling-temp-file.d.ts.map +0 -1
  111. package/dist/sibling-temp-file.js +0 -73
  112. package/dist/sibling-temp-write.d.ts +0 -8
  113. package/dist/sibling-temp-write.d.ts.map +0 -1
  114. package/dist/sibling-temp-write.js +0 -40
@@ -0,0 +1,147 @@
1
+ # Quickstart
2
+
3
+ Five minutes. By the end you will have a working `root()` and know how to read, write, atomically replace, and unpack an archive — without your code being able to escape the workspace.
4
+
5
+ If you have used Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), this is the same shape: a capability-style handle that carries the boundary across every operation. The first thing to internalize is that you stop reasoning about *paths* and start reasoning about *the handle*.
6
+
7
+ ## 1. Build a root
8
+
9
+ ```ts
10
+ import { root } from "@openclaw/fs-safe";
11
+
12
+ const fs = await root("/srv/jobs/incoming", {
13
+ hardlinks: "reject", // refuse files that are hardlinks of out-of-tree inodes
14
+ symlinks: "reject", // refuse to traverse a symlink during open
15
+ mkdir: true, // create missing parent dirs on write
16
+ });
17
+ ```
18
+
19
+ `root()` resolves the directory through the real filesystem (so symlinked roots become canonical) and verifies it exists. The defaults you pass apply to every call below; per-call options override them.
20
+
21
+ If the root directory itself does not exist yet, `root()` throws `FsSafeError` with code `not-found`. Either create the directory before calling `root()`, or call `await fs.ensureRoot()` after a successful `root()` to create empty subpaths.
22
+
23
+ ## 2. Read and write text
24
+
25
+ ```ts
26
+ await fs.write("notes/today.txt", "hello\n");
27
+ const text = await fs.readText("notes/today.txt");
28
+ ```
29
+
30
+ Writes use a sibling temp file plus `rename`, so a partial write never appears at the destination. Reads open with `O_NOFOLLOW` where available and verify the opened fd matches the path identity before returning the buffer.
31
+
32
+ `create()` is the don't-clobber variant of `write()` and throws `already-exists` when the target is already there:
33
+
34
+ ```ts
35
+ await fs.create("notes/README.md", "seed\n"); // throws if it already exists
36
+ ```
37
+
38
+ ## 3. JSON, with parsing
39
+
40
+ ```ts
41
+ type Config = { tokens: string[]; updatedAt: string };
42
+
43
+ await fs.writeJson("state/config.json", { tokens: [], updatedAt: new Date().toISOString() }, {
44
+ space: 2,
45
+ });
46
+
47
+ const config = await fs.readJson<Config>("state/config.json");
48
+ ```
49
+
50
+ `writeJson` stringifies and writes atomically. `readJson` reads through the same boundary and parses; validate the shape at your application boundary if it came from a less-trusted source.
51
+
52
+ ## 4. Move and remove
53
+
54
+ ```ts
55
+ await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true });
56
+ await fs.remove("notes/archive/today.txt");
57
+ ```
58
+
59
+ `move()` defaults to no clobber. Pass `{ overwrite: true }` when replacing the target is intentional. `remove()` works on files and empty directories. For non-empty directories, list and remove children first or use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
60
+
61
+ ## 5. Inspect
62
+
63
+ ```ts
64
+ const here = await fs.exists("state/config.json"); // boolean
65
+ const stat = await fs.stat("state/config.json"); // { kind, size, mtimeMs, ... }
66
+ const names = await fs.list("state"); // string[]
67
+ const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[]
68
+ ```
69
+
70
+ `exists`, `stat`, and `list` are boundary-checked but **do not pin a later operation** to the same filesystem object. For race-resistant reads or writes, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` — they pin the path identity at the point of use.
71
+
72
+ ## 6. Catch escapes
73
+
74
+ ```ts
75
+ import { FsSafeError } from "@openclaw/fs-safe";
76
+
77
+ try {
78
+ await fs.write("../escape.txt", "x");
79
+ } catch (err) {
80
+ if (err instanceof FsSafeError && err.code === "outside-workspace") {
81
+ // log, count, drop the request
82
+ } else {
83
+ throw err;
84
+ }
85
+ }
86
+ ```
87
+
88
+ Error codes are a closed union — branch on `err.code` instead of matching message text. The full list lives in the [Errors](errors.md) reference.
89
+
90
+ ## 7. Replace a config file atomically
91
+
92
+ ```ts
93
+ import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
94
+
95
+ await replaceFileAtomic({
96
+ filePath: "/srv/jobs/incoming/state/config.json",
97
+ content: JSON.stringify(state, null, 2),
98
+ mode: 0o600,
99
+ syncTempFile: true,
100
+ syncParentDir: true,
101
+ });
102
+ ```
103
+
104
+ Use `replaceFileAtomic` directly when you have an absolute path you trust and want sibling-temp + rename without going through `root()`. See [Atomic writes](atomic.md).
105
+
106
+ ## 8. Unpack a ZIP
107
+
108
+ ```ts
109
+ import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
110
+
111
+ const kind = resolveArchiveKind("upload.zip");
112
+ if (!kind) throw new Error("unsupported archive");
113
+
114
+ await extractArchive({
115
+ archivePath: "/srv/jobs/incoming/uploads/upload.zip",
116
+ destDir: "/srv/jobs/incoming/extracted",
117
+ kind,
118
+ timeoutMs: 15_000,
119
+ limits: {
120
+ maxArchiveBytes: 256 * 1024 * 1024,
121
+ maxEntries: 50_000,
122
+ maxExtractedBytes: 512 * 1024 * 1024,
123
+ maxEntryBytes: 256 * 1024 * 1024,
124
+ },
125
+ });
126
+ ```
127
+
128
+ Extraction stages into a private dir and merges through the same boundary used by direct writes, so a symlinked entry can't trick the merge into following an out-of-tree path. See [Archive extraction](archive.md).
129
+
130
+ ## 9. Get a private scratch directory
131
+
132
+ ```ts
133
+ import { withTempWorkspace } from "@openclaw/fs-safe/temp";
134
+
135
+ await withTempWorkspace({ rootDir: "/srv/jobs/tmp", prefix: "build-" }, async (workspace) => {
136
+ await fs.copyIn("input.bin", "/tmp/source.bin");
137
+ // ...do work in workspace.dir; auto-cleaned on exit
138
+ });
139
+ ```
140
+
141
+ The directory is mode `0700`, sits under a per-user secure temp root, and is removed when the callback returns or throws. See [Temp workspaces](temp.md).
142
+
143
+ ## Where to next
144
+
145
+ - [Security model](security-model.md) — exactly what the boundary defends against, what it does not.
146
+ - [Root API](root.md) — every method on the `Root` handle, including streaming and reader callbacks.
147
+ - [Errors](errors.md) — the full code union and what each one means.
@@ -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.