@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.
Files changed (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +122 -125
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
package/docs/types.md ADDED
@@ -0,0 +1,155 @@
1
+ # Types
2
+
3
+ The types most callers reach for. Shared data shapes are exported from `@openclaw/fs-safe/types`; method-specific option/result types live next to their subpath.
4
+
5
+ ```ts
6
+ import type {
7
+ BasePathOptions,
8
+ DirEntry,
9
+ FastPathMode,
10
+ PathStat,
11
+ SafeEncoding,
12
+ } from "@openclaw/fs-safe/types";
13
+ ```
14
+
15
+ ## `PathStat`
16
+
17
+ ```ts
18
+ type PathStat = {
19
+ kind: "file" | "directory" | "symlink" | "fifo" | "socket" | "blockDevice" | "characterDevice" | "unknown";
20
+ size: number; // bytes
21
+ mtimeMs: number; // milliseconds since epoch
22
+ mode: number; // POSIX mode bits
23
+ nlink: number; // hardlink count
24
+ };
25
+ ```
26
+
27
+ The shape returned by `Root.stat()`. A trimmed view of `node:fs.Stats` — only the fields the boundary cares about. Use `kind` instead of inspecting the various `is*` methods on a Node `Stats` object; it covers every case in one switchable string.
28
+
29
+ ## `DirEntry`
30
+
31
+ ```ts
32
+ type DirEntry = PathStat & {
33
+ name: string; // base name within the listed directory
34
+ };
35
+ ```
36
+
37
+ Returned by `Root.list(rel, { withFileTypes: true })`. Includes the same `kind`/`size`/etc as `PathStat`, plus the entry's `name`.
38
+
39
+ ## `BasePathOptions`
40
+
41
+ ```ts
42
+ type BasePathOptions = {
43
+ fastPathMode?: FastPathMode;
44
+ };
45
+
46
+ type FastPathMode = "auto" | "never" | "require";
47
+ ```
48
+
49
+ Options shared by helpers that can take a "fast path" (use cheaper syscalls when the input is already absolute and clearly inside scope). The default is `"auto"` — let the helper pick. Force `"never"` in tests if you want to exercise the slow path. Force `"require"` if you need to assert that the fast path is taken (the helper throws if it can't).
50
+
51
+ Most callers don't need to touch this.
52
+
53
+ ## `SafeEncoding`
54
+
55
+ ```ts
56
+ type SafeEncoding = BufferEncoding | null;
57
+ ```
58
+
59
+ Used by helpers that accept either an encoding (returning a string) or `null` (returning a `Buffer`). The Node `BufferEncoding` type is widened to include `null` for "give me bytes."
60
+
61
+ ## `OpenResult` / `ReadResult`
62
+
63
+ Returned by `Root.open()` and `Root.read()`:
64
+
65
+ ```ts
66
+ type OpenResult = {
67
+ handle: import("node:fs/promises").FileHandle;
68
+ realPath: string;
69
+ stat: import("node:fs").Stats;
70
+ };
71
+
72
+ type ReadResult = {
73
+ buffer: Buffer;
74
+ realPath: string;
75
+ stat: import("node:fs").Stats;
76
+ };
77
+ ```
78
+
79
+ `realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result.
80
+
81
+ ## `RootDefaults` / `RootOptions`
82
+
83
+ ```ts
84
+ type RootDefaults = {
85
+ hardlinks?: "reject" | "allow";
86
+ maxBytes?: number;
87
+ mkdir?: boolean;
88
+ mode?: number;
89
+ nonBlockingRead?: boolean;
90
+ symlinks?: "reject" | "follow-within-root";
91
+ };
92
+
93
+ type RootOptions = {
94
+ rootDir: string;
95
+ defaults?: RootDefaults;
96
+ };
97
+ ```
98
+
99
+ `RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these.
100
+
101
+ ## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
102
+
103
+ ```ts
104
+ type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
105
+ type RootWriteOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
106
+ encoding?: BufferEncoding;
107
+ overwrite?: boolean;
108
+ };
109
+ type RootCopyOptions = Pick<RootDefaults, "maxBytes" | "mkdir" | "mode"> & {
110
+ sourceHardlinks?: "reject" | "allow";
111
+ };
112
+ type RootOpenWritableOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
113
+ writeMode?: "replace" | "append" | "update";
114
+ };
115
+ type RootWriteJsonOptions = RootWriteOptions & {
116
+ replacer?: Parameters<typeof JSON.stringify>[1];
117
+ space?: Parameters<typeof JSON.stringify>[2];
118
+ trailingNewline?: boolean;
119
+ };
120
+ type RootAppendOptions = RootWriteOptions & {
121
+ prependNewlineIfNeeded?: boolean;
122
+ };
123
+ ```
124
+
125
+ Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
126
+
127
+ ## `SymlinkPolicy` / `HardlinkPolicy`
128
+
129
+ ```ts
130
+ type SymlinkPolicy = "reject" | "follow-within-root";
131
+ type HardlinkPolicy = "reject" | "allow";
132
+ ```
133
+
134
+ The two policy unions you'll see throughout. `"reject"` is conservative; `"follow-within-root"` allows symlinks whose final target is still inside the root; `"allow"` (hardlinks only) is permissive. Defaults for both symlinks and hardlinks are `"reject"`; switch hardlinks to `"allow"` only when you intentionally accept hardlink aliases.
135
+
136
+ ## `FsSafeErrorCode` / `FsSafeErrorCategory`
137
+
138
+ ```ts
139
+ type FsSafeErrorCode =
140
+ | "already-exists" | "hardlink" | "helper-failed" | "helper-unavailable"
141
+ | "insecure-permissions" | "invalid-path" | "not-empty" | "not-file"
142
+ | "not-found" | "not-owned" | "not-removable" | "outside-workspace"
143
+ | "path-alias" | "path-mismatch" | "permission-unverified"
144
+ | "symlink" | "timeout" | "too-large" | "unsupported-platform";
145
+ ```
146
+
147
+ Closed union you switch on. See the [Errors](errors.md) reference for what each one means.
148
+
149
+ `FsSafeError.category` is `"policy"` for unsafe input/target-state failures and `"operational"` for environment/runtime failures.
150
+
151
+ ## See also
152
+
153
+ - [`root()`](root.md) — how `RootDefaults` and `Root*Options` are used.
154
+ - [Errors](errors.md) — the closed code union in context.
155
+ - [Reading](reading.md), [Writing](writing.md) — option shapes per verb.
package/docs/walk.md ADDED
@@ -0,0 +1,63 @@
1
+ # Directory walking
2
+
3
+ `walkDirectory()` and `walkDirectorySync()` provide budget-bounded directory scans for callers that would otherwise hand-roll recursive `readdir()` loops. The bounds are `maxDepth` and `maxEntries`; this helper does not create a security boundary. Use [`root()`](root.md) when the path itself is caller-influenced.
4
+
5
+ ```ts
6
+ import { walkDirectory } from "@openclaw/fs-safe/walk";
7
+
8
+ const scan = await walkDirectory("/safe/workspace", {
9
+ maxDepth: 3,
10
+ maxEntries: 10_000,
11
+ symlinks: "skip",
12
+ include: (entry) => entry.kind === "file",
13
+ descend: (entry) => entry.name !== ".git",
14
+ });
15
+
16
+ if (scan.truncated) {
17
+ throw new Error("workspace scan exceeded entry budget");
18
+ }
19
+ ```
20
+
21
+ ## Result
22
+
23
+ ```ts
24
+ type WalkDirectoryResult = {
25
+ entries: WalkDirectoryEntry[];
26
+ scannedEntryCount: number;
27
+ truncated: boolean;
28
+ };
29
+
30
+ type WalkDirectoryEntry = {
31
+ name: string;
32
+ path: string;
33
+ relativePath: string;
34
+ depth: number;
35
+ kind: "file" | "directory" | "symlink" | "other";
36
+ dirent: import("node:fs").Dirent;
37
+ };
38
+ ```
39
+
40
+ `depth` starts at `1` for direct children of `rootDir`. `relativePath` is always relative to the supplied root. `scannedEntryCount` counts directory entries examined, including entries filtered out by `include`.
41
+
42
+ ## Options
43
+
44
+ ```ts
45
+ type WalkDirectoryOptions = {
46
+ maxDepth?: number;
47
+ maxEntries?: number;
48
+ symlinks?: "skip" | "follow" | "include";
49
+ include?: (entry: WalkDirectoryEntry) => boolean;
50
+ descend?: (entry: WalkDirectoryEntry) => boolean;
51
+ };
52
+ ```
53
+
54
+ `symlinks` defaults to `"skip"`. `"include"` returns symlink entries without following them. `"follow"` resolves symlinks with `stat()` and may descend into linked directories, so use it only when that is intentional. Already-visited real directories are skipped so symlink cycles do not recurse forever.
55
+
56
+ `include` controls which entries are returned. `descend` controls which directory entries are traversed. A skipped directory can still be returned if `include` accepts it.
57
+
58
+ Unreadable directories are skipped. This makes the helper suitable for best-effort inventories and pruning jobs; use a stricter root-bounded operation when every entry must be accounted for.
59
+
60
+ ## See also
61
+
62
+ - [`fileStore`](file-store.md) — managed stores use bounded walking for pruning.
63
+ - [Path scopes](path-scope.md) — boundary checks for known absolute paths.
@@ -0,0 +1,222 @@
1
+ # Writing
2
+
3
+ The `Root` handle exposes a tight set of write verbs. Each one is atomic at the destination — no half-written intermediate state — and goes through the same boundary checks as reads.
4
+
5
+ ```ts
6
+ await fs.write("state.json", body);
7
+ await fs.create("seed.json", initial); // throws if exists
8
+ await fs.writeJson("config.json", state);
9
+ await fs.append("logs/today.log", line);
10
+ await fs.copyIn("inbox/upload.bin", "/tmp/upload.bin");
11
+ await fs.move("notes/draft.md", "notes/published.md");
12
+ await fs.remove("logs/yesterday.log");
13
+ await fs.mkdir("snapshots/2026/05");
14
+ ```
15
+
16
+ ## What every write does
17
+
18
+ 1. Resolve the relative target against the canonical root and reject anything that escapes (`outside-workspace`).
19
+ 2. If `mkdir: true`, create missing parent directories with the parent fd pinned.
20
+ 3. Open the parent directory by fd. Subsequent rename/unlink uses the parent fd, not the path string, so a parent-directory symlink swap mid-call cannot divert the write.
21
+ 4. Write data to a sibling temp file in the same directory.
22
+ 5. Atomically rename the temp file over the destination.
23
+ 6. Stat the resulting fd and verify identity.
24
+
25
+ A failure at any point either leaves the destination at its previous contents or surfaces an `FsSafeError` — never a partially-written file at the destination path.
26
+
27
+ ## Write verbs
28
+
29
+ ### `fs.write(rel, data, options?)`
30
+
31
+ Overwrite or create. Always atomic.
32
+
33
+ ```ts
34
+ await fs.write("state/last-run.json", JSON.stringify(run));
35
+ await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
36
+ ```
37
+
38
+ `data` accepts `string | Buffer`. `options` are `{ encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }`. `mode` sets the file's POSIX mode; if omitted, falls back to the `mode` from `RootDefaults` and then to umask. `overwrite` defaults to `true`; set it to `false` for the same no-clobber behavior as `create()`.
39
+
40
+ ### `fs.create(rel, data, options?)`
41
+
42
+ Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
43
+
44
+ ```ts
45
+ try {
46
+ await fs.create("config/seed.json", initial);
47
+ } catch (err) {
48
+ if (err instanceof FsSafeError && err.code !== "already-exists") throw err;
49
+ }
50
+ ```
51
+
52
+ ### `fs.writeJson(rel, value, options?)`
53
+
54
+ `JSON.stringify(value, replacer, space)` + atomic write. Adds a trailing newline by default.
55
+
56
+ ```ts
57
+ await fs.writeJson("config.json", state, { space: 2 });
58
+ await fs.writeJson("compact.json", state, { trailingNewline: false });
59
+ ```
60
+
61
+ Options:
62
+
63
+ ```ts
64
+ type RootWriteJsonOptions = {
65
+ encoding?: BufferEncoding;
66
+ mkdir?: boolean;
67
+ mode?: number;
68
+ replacer?: (this: any, key: string, value: any) => any | (number | string)[];
69
+ space?: number | string;
70
+ trailingNewline?: boolean; // default true
71
+ };
72
+ ```
73
+
74
+ `createJson(rel, value, options?)` is the don't-clobber variant.
75
+
76
+ ### `fs.append(rel, data, options?)`
77
+
78
+ Open in append mode, write, close. Honors `mkdir` for the parent directory. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
79
+
80
+ ```ts
81
+ await fs.append("logs/today.log", `[${ts}] ${line}\n`);
82
+ await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
83
+ ```
84
+
85
+ For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle.
86
+
87
+ ### `fs.copyIn(rel, sourceAbsPath, options?)`
88
+
89
+ Bring a file from outside the root into the root, atomically. The source path must be absolute. The library streams the source through the boundary, writes to a sibling temp, and renames over the destination.
90
+
91
+ ```ts
92
+ await fs.copyIn("inbox/upload.bin", "/tmp/incoming.bin", {
93
+ maxBytes: 64 * 1024 * 1024,
94
+ });
95
+ ```
96
+
97
+ Options: `{ encoding?, mkdir?, maxBytes?, sourceHardlinks? }`. Use `sourceHardlinks: "reject"` to refuse if the source itself is a hardlinked alias.
98
+
99
+ ### `fs.move(from, to, options?)`
100
+
101
+ Rename one path inside the root to another. Defaults to no clobber:
102
+
103
+ ```ts
104
+ await fs.move("incoming/foo.txt", "archive/foo.txt");
105
+ await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });
106
+ ```
107
+
108
+ Both `from` and `to` are bounded; `..` in either is rejected.
109
+
110
+ ### `fs.remove(rel)`
111
+
112
+ Unlink a file or `rmdir` an empty directory. Non-empty directories throw `not-empty`. For atomic directory replacement, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
113
+
114
+ ```ts
115
+ await fs.remove("logs/yesterday.log");
116
+ await fs.remove("snapshots/empty-dir"); // ok
117
+ await fs.remove("snapshots/full-dir"); // throws not-empty
118
+ ```
119
+
120
+ ### `fs.mkdir(rel)`
121
+
122
+ `mkdir -p`. Creates missing parents.
123
+
124
+ ```ts
125
+ await fs.mkdir("snapshots/2026/05");
126
+ ```
127
+
128
+ ### `fs.ensureRoot()`
129
+
130
+ Treats `""` / `"."` as the root itself. Useful when a generic helper computes a relative directory and might end up at the root.
131
+
132
+ ```ts
133
+ const targetRel = path.relative(fs.rootReal, candidateAbs); // could be "" if candidateAbs === root
134
+ await fs.ensureRoot(); // accepts "" without throwing
135
+ ```
136
+
137
+ ## `openWritable()` for streaming
138
+
139
+ When `write` doesn't fit (very large outputs, slow producers), open a writable handle:
140
+
141
+ ```ts
142
+ const opened = await fs.openWritable("logs/current.log", { writeMode: "append" });
143
+ try {
144
+ for await (const chunk of source) {
145
+ await opened.handle.appendFile(chunk);
146
+ }
147
+ } finally {
148
+ await opened.handle.close();
149
+ }
150
+ ```
151
+
152
+ Options: `{ mkdir?, mode?, writeMode? }`, where `writeMode` is `"replace"` (default), `"append"`, or `"update"`. `replace` truncates existing files; `update` keeps existing contents. Streaming writes go directly to the destination — there is no atomic-rename step. If you need both streaming and atomicity, write to a sibling temp yourself and rename when done; the [`atomic`](atomic.md) helpers can do this for you.
153
+
154
+ ## Write defaults vs per-call options
155
+
156
+ Set `mkdir: true` once on `root()`; pass text encodings per call when needed:
157
+
158
+ ```ts
159
+ const fs = await root("/srv/workspace", {
160
+ mkdir: true,
161
+ });
162
+
163
+ await fs.write("notes/today.txt", "ascii", { encoding: "utf8" });
164
+ await fs.write("data/blob.bin", buffer); // mkdir true, no encoding because data is Buffer
165
+ await fs.write("data/blob.bin", buffer, { mkdir: false }); // override
166
+ ```
167
+
168
+ ## Errors you'll catch
169
+
170
+ | Code | When |
171
+ |---|---|
172
+ | `outside-workspace` | Target resolves outside the root. |
173
+ | `already-exists` | `create()` / `createJson()` / `move({ overwrite: false })` hit an existing target. |
174
+ | `not-found` | Parent does not exist and `mkdir` is false. |
175
+ | `not-empty` | `remove()` on a non-empty directory. |
176
+ | `not-removable` | `remove()` could not unlink/rmdir (typically permissions or device busy). |
177
+ | `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer. |
178
+ | `too-large` | `copyIn()` source exceeded `maxBytes`. |
179
+ | `symlink` | A path component is a symlink and policy is `reject`. |
180
+ | `hardlink` | `sourceHardlinks: "reject"` saw `nlink > 1`. |
181
+
182
+ Full list in [Errors](errors.md).
183
+
184
+ ## Common patterns
185
+
186
+ ### Replace if changed
187
+
188
+ ```ts
189
+ const next = JSON.stringify(state);
190
+ const prev = await fs.readText("state.json").catch(() => "");
191
+ if (prev !== next) await fs.write("state.json", next);
192
+ ```
193
+
194
+ ### Stage many writes, then commit
195
+
196
+ ```ts
197
+ const stagingDir = "snapshots/incoming";
198
+ await fs.mkdir(stagingDir);
199
+ for (const file of files) await fs.write(`${stagingDir}/${file.name}`, file.body);
200
+ await fs.move(stagingDir, "snapshots/2026-05-05", { overwrite: true });
201
+ ```
202
+
203
+ For a true commit-or-rollback over a *directory*, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
204
+
205
+ ### Rotate logs
206
+
207
+ ```ts
208
+ const today = `logs/${formatDate(new Date())}.log`;
209
+ try {
210
+ await fs.create(today, "");
211
+ } catch (err) {
212
+ if (!(err instanceof FsSafeError) || err.code !== "already-exists") throw err;
213
+ }
214
+ await fs.append(today, line);
215
+ ```
216
+
217
+ ## See also
218
+
219
+ - [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` and friends.
220
+ - [JSON files](json.md) — standalone JSON helpers without going through `root()`.
221
+ - [Reading](reading.md) — companion read API.
222
+ - [Errors](errors.md) — every code, when it fires.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,6 +11,7 @@
11
11
  "dist/**/*.js",
12
12
  "dist/**/*.d.ts",
13
13
  "dist/**/*.d.ts.map",
14
+ "docs/**/*.md",
14
15
  "README.md",
15
16
  "CHANGELOG.md",
16
17
  "SECURITY.md",
@@ -35,6 +36,10 @@
35
36
  "types": "./dist/path.d.ts",
36
37
  "default": "./dist/path.js"
37
38
  },
39
+ "./output": {
40
+ "types": "./dist/output.d.ts",
41
+ "default": "./dist/output.js"
42
+ },
38
43
  "./advanced": {
39
44
  "types": "./dist/advanced.d.ts",
40
45
  "default": "./dist/advanced.js"
@@ -95,10 +100,13 @@
95
100
  "scripts": {
96
101
  "benchmark": "node scripts/benchmark.mjs",
97
102
  "build": "tsc -p tsconfig.json",
103
+ "lint:file-size": "node scripts/check-file-size.mjs",
104
+ "lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
98
105
  "prepack": "node scripts/prepack-build.mjs",
99
106
  "test": "vitest run",
100
107
  "test:coverage": "vitest run --coverage",
101
- "check": "pnpm build && pnpm test",
108
+ "test:security": "vitest run test/fs-safe.test.ts test/openclaw-read-bypass-parity.test.ts test/openclaw-write-bypass-parity.test.ts test/additional-bypass-parity.test.ts test/adversarial-boundary-payloads.test.ts",
109
+ "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
102
110
  "docs:site": "node scripts/build-docs-site.mjs"
103
111
  },
104
112
  "optionalDependencies": {
@@ -1,3 +0,0 @@
1
- export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T>;
2
- export declare function fileExists(filePath: string): Promise<boolean>;
3
- //# sourceMappingURL=archive-utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"archive-utils.d.ts","sourceRoot":"","sources":["../src/archive-utils.ts"],"names":[],"mappings":"AAEA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOnE"}
@@ -1,26 +0,0 @@
1
- import fs from "node:fs/promises";
2
- export async function withTimeout(promise, timeoutMs, label) {
3
- let timeoutId;
4
- try {
5
- return await Promise.race([
6
- promise,
7
- new Promise((_, reject) => {
8
- timeoutId = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
9
- }),
10
- ]);
11
- }
12
- finally {
13
- if (timeoutId) {
14
- clearTimeout(timeoutId);
15
- }
16
- }
17
- }
18
- export async function fileExists(filePath) {
19
- try {
20
- await fs.stat(filePath);
21
- return true;
22
- }
23
- catch {
24
- return false;
25
- }
26
- }
@@ -1,44 +0,0 @@
1
- import fs from "node:fs";
2
- import type { PathAliasPolicy } from "./path-alias-guards.js";
3
- import { type SafeOpenSyncAllowedType, type SafeOpenSyncFailureReason } from "./safe-open-sync.js";
4
- type BoundaryReadFs = Pick<typeof fs, "closeSync" | "constants" | "fstatSync" | "lstatSync" | "openSync" | "readFileSync" | "realpathSync">;
5
- export type BoundaryFileOpenFailureReason = SafeOpenSyncFailureReason | "validation";
6
- export type BoundaryFileOpenResult = {
7
- ok: true;
8
- path: string;
9
- fd: number;
10
- stat: fs.Stats;
11
- rootRealPath: string;
12
- } | {
13
- ok: false;
14
- reason: BoundaryFileOpenFailureReason;
15
- error?: unknown;
16
- };
17
- export type BoundaryFileOpenFailure = Extract<BoundaryFileOpenResult, {
18
- ok: false;
19
- }>;
20
- export type OpenBoundaryFileSyncParams = {
21
- absolutePath: string;
22
- rootPath: string;
23
- boundaryLabel: string;
24
- rootRealPath?: string;
25
- maxBytes?: number;
26
- rejectHardlinks?: boolean;
27
- allowedType?: SafeOpenSyncAllowedType;
28
- skipLexicalRootCheck?: boolean;
29
- ioFs?: BoundaryReadFs;
30
- };
31
- export type OpenBoundaryFileParams = OpenBoundaryFileSyncParams & {
32
- aliasPolicy?: PathAliasPolicy;
33
- };
34
- export declare function canUseBoundaryFileOpen(ioFs: typeof fs): boolean;
35
- export declare function openBoundaryFileSync(params: OpenBoundaryFileSyncParams): BoundaryFileOpenResult;
36
- export declare function matchBoundaryFileOpenFailure<T>(failure: BoundaryFileOpenFailure, handlers: {
37
- path?: (failure: BoundaryFileOpenFailure) => T;
38
- validation?: (failure: BoundaryFileOpenFailure) => T;
39
- io?: (failure: BoundaryFileOpenFailure) => T;
40
- fallback: (failure: BoundaryFileOpenFailure) => T;
41
- }): T;
42
- export declare function openBoundaryFile(params: OpenBoundaryFileParams): Promise<BoundaryFileOpenResult>;
43
- export {};
44
- //# sourceMappingURL=boundary-file-read.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"boundary-file-read.d.ts","sourceRoot":"","sources":["../src/boundary-file-read.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAOzB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAEL,KAAK,uBAAuB,EAC5B,KAAK,yBAAyB,EAC/B,MAAM,qBAAqB,CAAC;AAE7B,KAAK,cAAc,GAAG,IAAI,CACxB,OAAO,EAAE,EACP,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,UAAU,GACV,cAAc,GACd,cAAc,CACjB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,yBAAyB,GAAG,YAAY,CAAC;AAErF,MAAM,MAAM,sBAAsB,GAC9B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAC5E;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,6BAA6B,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE1E,MAAM,MAAM,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,EAAE;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAAC,CAAC;AAErF,MAAM,MAAM,0BAA0B,GAAG;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,WAAW,CAAC,EAAE,uBAAuB,CAAC;IACtC,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,IAAI,CAAC,EAAE,cAAc,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG,0BAA0B,GAAG;IAChE,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAQF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,OAAO,CAW/D;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,0BAA0B,GAAG,sBAAsB,CAuB/F;AAED,wBAAgB,4BAA4B,CAAC,CAAC,EAC5C,OAAO,EAAE,uBAAuB,EAChC,QAAQ,EAAE;IACR,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,CAAC,CAAC;IAC/C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,CAAC,CAAC;IACrD,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,CAAC,CAAC;IAC7C,QAAQ,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,CAAC,CAAC;CACnD,GACA,CAAC,CAUH;AAoDD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,sBAAsB,GAC7B,OAAO,CAAC,sBAAsB,CAAC,CAsBjC"}