@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
package/docs/errors.md ADDED
@@ -0,0 +1,154 @@
1
+ # Errors
2
+
3
+ Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
4
+
5
+ ```ts
6
+ import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";
7
+ ```
8
+
9
+ ## Shape
10
+
11
+ ```ts
12
+ class FsSafeError extends Error {
13
+ readonly name: "FsSafeError";
14
+ readonly code: FsSafeErrorCode;
15
+ readonly category: "policy" | "operational";
16
+
17
+ constructor(code: FsSafeErrorCode, message: string, options?: { cause?: unknown });
18
+ }
19
+ ```
20
+
21
+ `cause` is available through the standard `Error` `cause` property when the failure was triggered by a `NodeJS.ErrnoException` (e.g. a wrapped `EACCES`). Inspect it for the original `code` / `errno` / `syscall` if you need finer-grained reporting.
22
+
23
+ `category` separates caller-policy failures from operational failures:
24
+
25
+ - `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
26
+ - `"operational"` — environment/runtime failures, such as helper startup, platform support, timeout, or unverifiable permissions.
27
+
28
+ ## Code union
29
+
30
+ ```ts
31
+ type FsSafeErrorCode =
32
+ | "already-exists"
33
+ | "hardlink"
34
+ | "helper-failed"
35
+ | "helper-unavailable"
36
+ | "insecure-permissions"
37
+ | "invalid-path"
38
+ | "not-empty"
39
+ | "not-file"
40
+ | "not-found"
41
+ | "not-owned"
42
+ | "not-removable"
43
+ | "outside-workspace"
44
+ | "path-alias"
45
+ | "path-mismatch"
46
+ | "permission-unverified"
47
+ | "symlink"
48
+ | "timeout"
49
+ | "too-large"
50
+ | "unsupported-platform";
51
+ ```
52
+
53
+ ## Code reference
54
+
55
+ | Code | When it fires | Common causes |
56
+ |---|---|---|
57
+ | `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
58
+ | `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
59
+ | `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
60
+ | `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
61
+ | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
62
+ | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
63
+ | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
64
+ | `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
65
+ | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
66
+ | `not-owned` | A secure file owner check failed. | File is owned by another UID. |
67
+ | `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
68
+ | `outside-workspace` | Path resolves outside the configured root. | `..` traversal; absolute path outside the root; symlink resolved out. |
69
+ | `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
70
+ | `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
71
+ | `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
72
+ | `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
73
+ | `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
74
+ | `too-large` | Read exceeded `maxBytes`. | Caller gave a too-permissive file or didn't size-cap correctly. |
75
+ | `unsupported-platform` | The requested operation is not supported on the current platform. | E.g. POSIX-only helper invoked on Windows. |
76
+
77
+ ## Branching
78
+
79
+ ```ts
80
+ import { FsSafeError } from "@openclaw/fs-safe";
81
+
82
+ try {
83
+ await fs.write("../escape.txt", "x");
84
+ } catch (err) {
85
+ if (!(err instanceof FsSafeError)) throw err;
86
+ switch (err.code) {
87
+ case "outside-workspace":
88
+ return reply(400, "path escapes workspace");
89
+ case "already-exists":
90
+ return reply(409, "exists");
91
+ case "too-large":
92
+ return reply(413, "too large");
93
+ case "not-found":
94
+ return reply(404, "missing");
95
+ case "symlink":
96
+ case "hardlink":
97
+ case "path-mismatch":
98
+ case "path-alias":
99
+ return reply(400, "unsafe path");
100
+ default:
101
+ throw err;
102
+ }
103
+ }
104
+ ```
105
+
106
+ The compiler will flag missing cases when you exhaust the union — keep your switch up-to-date as the library adds new codes.
107
+
108
+ ## Distinguishing from `NodeJS.ErrnoException`
109
+
110
+ Some failures bubble up as native Node errors (e.g. `EACCES`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
111
+
112
+ ```ts
113
+ import { FsSafeError } from "@openclaw/fs-safe";
114
+
115
+ try {
116
+ await op();
117
+ } catch (err) {
118
+ if (err instanceof FsSafeError) {
119
+ handleFsSafe(err);
120
+ return;
121
+ }
122
+ if ((err as NodeJS.ErrnoException).code === "EACCES") {
123
+ handleAccess();
124
+ return;
125
+ }
126
+ throw err;
127
+ }
128
+ ```
129
+
130
+ A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.
131
+
132
+ ## Specialty errors
133
+
134
+ A handful of helpers throw their own typed errors instead of `FsSafeError`:
135
+
136
+ - `JsonFileReadError` — thrown by [`readJson`](json.md). Carries `cause` so you can distinguish missing (`ENOENT`) from invalid (`SyntaxError`).
137
+ - `ArchiveLimitError` — thrown by [`extractArchive`](archive.md) when an archive size, entry count, or extracted-byte budget is exceeded. The `code` field uses `ARCHIVE_LIMIT_ERROR_CODE` constants (e.g. `"ARCHIVE_SIZE_EXCEEDS_LIMIT"`).
138
+ - `ArchiveSecurityError` — thrown by extraction when an entry path violates safety rules (traversal, drive prefix, blocked link type). The `code` field uses `ArchiveSecurityErrorCode` values.
139
+
140
+ These are exported from their respective subpaths.
141
+
142
+ ## Why `FsSafeError`?
143
+
144
+ Two reasons it isn't a richer hierarchy of subclasses:
145
+
146
+ 1. **Switch on `code`, don't `instanceof` a tree.** `code` is a closed string union the TypeScript compiler can exhaust-check. Subclasses make `instanceof` ladders that drift over time.
147
+ 2. **One catch handler.** Library callers often want a single "is this an `fs-safe` failure?" gate before deciding what to do — `instanceof FsSafeError` plus a switch is the cleanest expression of that.
148
+
149
+ ## See also
150
+
151
+ - [`root()`](root.md) — every method documents the codes it can throw.
152
+ - [Reading](reading.md) — read-path codes.
153
+ - [Writing](writing.md) — write-path codes.
154
+ - [Archive extraction](archive.md) — `ArchiveLimitError` and `ArchiveSecurityError`.
@@ -0,0 +1,213 @@
1
+ # File store
2
+
3
+ `fileStore` is exported from `@openclaw/fs-safe/store`. It is a managed wrapper around `root()` for the common "store files under a directory at known modes, prune old ones, hand back absolute paths" pattern. Useful for caches, ingest staging, generated artifacts, anywhere the consumer wants object-style access plus stream and copy primitives.
4
+
5
+ ```ts
6
+ import {
7
+ fileStore,
8
+ type FileStore,
9
+ type FileStoreOptions,
10
+ type FileStoreWriteOptions,
11
+ type FileStorePruneOptions,
12
+ } from "@openclaw/fs-safe/store";
13
+ ```
14
+
15
+ ## When to reach for it
16
+
17
+ - You want a single directory holding files written by your code, with consistent mode bits and atomic placement.
18
+ - You want a `FileStore.write(rel, data)` / `read(rel)` / `pruneExpired(...)` interface.
19
+ - You want to feed a stream into the store with a byte cap.
20
+ - You don't need the full `Root` surface (move, list, mkdir, …); the store can hand you a real `Root` via `.root()` when you do.
21
+
22
+ ## Factory: `fileStore(options)`
23
+
24
+ ```ts
25
+ const cache = fileStore({
26
+ rootDir: "/var/cache/app",
27
+ mode: 0o600, // file mode for writes (default 0o600)
28
+ dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
29
+ maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
30
+ private: true, // use secret-file atomic writes for private state
31
+ });
32
+ ```
33
+
34
+ Use `private: true` for credentials, auth profiles, tokens, and other private
35
+ state. Private mode keeps the same `FileStore` shape but routes writes through
36
+ the secret-file atomic path, refusing symlink parent components and re-asserting
37
+ mode after rename.
38
+
39
+ Returns a `FileStore`:
40
+
41
+ ```ts
42
+ type FileStore = {
43
+ readonly rootDir: string;
44
+ path(relativePath: string): string;
45
+ root(): Promise<Root>;
46
+ write(rel, data: string | Buffer, options?): Promise<string>;
47
+ writeStream(rel, stream: Readable, options?): Promise<string>;
48
+ copyIn(rel, sourcePath: string, options?): Promise<string>;
49
+ open(rel, options?): Promise<OpenResult>;
50
+ read(rel, options?): Promise<ReadResult>;
51
+ readBytes(rel, options?): Promise<Buffer>;
52
+ readText(rel, options?): Promise<string>;
53
+ readTextIfExists(rel, options?): Promise<string | null>;
54
+ readJson<T = unknown>(rel, options?): Promise<T>;
55
+ readJsonIfExists<T = unknown>(rel, options?): Promise<T | null>;
56
+ writeText(rel, data: string | Uint8Array, options?): Promise<string>;
57
+ writeJson(rel, data: unknown, options?): Promise<string>;
58
+ json<T = unknown>(rel, options?): JsonStore<T>;
59
+ remove(rel): Promise<void>;
60
+ exists(rel): Promise<boolean>;
61
+ pruneExpired(options: FileStorePruneOptions): Promise<void>;
62
+ };
63
+ ```
64
+
65
+ `path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
66
+
67
+ `root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
68
+
69
+ ## Writes
70
+
71
+ Every write goes through `writeSiblingTempFile` — temp + rename, mode applied to file and parent dir, both `fsync`'d.
72
+
73
+ ### `write(rel, data, options?)`
74
+
75
+ ```ts
76
+ const path = await cache.write("entries/2026/05/05.json", JSON.stringify(entry));
77
+ ```
78
+
79
+ Buffer or string. Returns the final absolute path. Throws `too-large` if `data.byteLength` exceeds `maxBytes`.
80
+
81
+ ### `writeText(rel, data, options?)` / `writeJson(rel, data, options?)`
82
+
83
+ Convenience wrappers over `write`. `writeJson` pretty-prints with a trailing newline by default and accepts `{ trailingNewline: false }` when the exact bytes matter.
84
+
85
+ ### `json<T>(rel, options?)`
86
+
87
+ Returns a typed single-file JSON state helper for a file under this store. It
88
+ inherits the store's root, mode, max-size, and private-write policy, then adds
89
+ `readOr`, `readRequired`, `update`, `updateOr`, and optional sidecar locking:
90
+
91
+ ```ts
92
+ const state = cache.json<State>("state/settings.json", { lock: true });
93
+ await state.updateOr(defaultState, (current) => ({ ...current, enabled: true }));
94
+ ```
95
+
96
+ Use this when one JSON file owns one piece of state. `jsonStore({ filePath })`
97
+ is the absolute-path convenience wrapper for the same primitive.
98
+
99
+ ### `writeStream(rel, stream, options?)`
100
+
101
+ ```ts
102
+ import { Readable } from "node:stream";
103
+ const path = await cache.writeStream("downloads/blob.bin", Readable.from(remoteFetch));
104
+ ```
105
+
106
+ Streams into a sibling temp with a running byte budget. Aborts the source stream with `too-large` if `maxBytes` is exceeded mid-stream — partial writes are cleaned up.
107
+
108
+ ### `copyIn(rel, sourcePath, options?)`
109
+
110
+ ```ts
111
+ const path = await cache.copyIn("ingest/upload.bin", "/tmp/upload.bin");
112
+ ```
113
+
114
+ One-shot ingest from an absolute source path. Source is checked for symlink/non-regular before copy. Same mode rules as `write`.
115
+
116
+ ### `FileStoreWriteOptions`
117
+
118
+ Per-call overrides for the store-level defaults:
119
+
120
+ ```ts
121
+ type FileStoreWriteOptions = {
122
+ dirMode?: number;
123
+ mode?: number;
124
+ maxBytes?: number;
125
+ tempPrefix?: string; // override the default "." + basename
126
+ };
127
+ ```
128
+
129
+ ## Reads
130
+
131
+ `open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
132
+
133
+ ## `remove(rel)` / `exists(rel)`
134
+
135
+ Forward to the underlying `Root`. `remove` unlinks files and `rmdir`s empty directories; non-empty dirs throw `not-empty`.
136
+
137
+ ## `pruneExpired(options)`
138
+
139
+ Walk the store and delete files older than `options.ttlMs`:
140
+
141
+ ```ts
142
+ await cache.pruneExpired({
143
+ ttlMs: 7 * 24 * 60 * 60 * 1000, // 7 days
144
+ recursive: true,
145
+ pruneEmptyDirs: true,
146
+ });
147
+ ```
148
+
149
+ Options:
150
+
151
+ ```ts
152
+ type FileStorePruneOptions = {
153
+ ttlMs: number;
154
+ recursive?: boolean; // default false (top-level only)
155
+ maxDepth?: number; // bound recursion explicitly
156
+ pruneEmptyDirs?: boolean; // also remove dirs that became empty (only with recursive/maxDepth)
157
+ };
158
+ ```
159
+
160
+ Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against `mtimeMs`.
161
+
162
+ ## Difference from `Root`
163
+
164
+ | `FileStore` | `Root` |
165
+ |---|---|
166
+ | Object-style with mode+dirMode baked in. | Method-style boundary; mode is per-call or per-default. |
167
+ | `writeStream` with built-in byte budget. | Manual via `openWritable()`. |
168
+ | `writeText` / `writeJson` return the final absolute path. | `Root.write` / `writeJson` return void. |
169
+ | `copyIn` returns the final absolute path. | `Root.copyIn` returns void. |
170
+ | `pruneExpired` walks by `mtime`. | No prune helper. |
171
+ | Reads delegate via `Root` internally. | The boundary itself. |
172
+
173
+ If you need richer ops (move, list, append, mkdir), call `store.root()` to get a `Root` and use that.
174
+
175
+ ## Common patterns
176
+
177
+ ### Cache with TTL prune
178
+
179
+ ```ts
180
+ const cache = fileStore({ rootDir: "/var/cache/app", maxBytes: 16 * 1024 * 1024 });
181
+
182
+ await cache.writeStream(`${id}.bin`, fetchStream(id));
183
+
184
+ // Background prune every hour
185
+ setInterval(() => cache.pruneExpired({ ttlMs: 24 * 60 * 60 * 1000 }), 60 * 60 * 1000);
186
+ ```
187
+
188
+ ### Ingest pipeline
189
+
190
+ ```ts
191
+ const ingest = fileStore({ rootDir: "/srv/ingest", mode: 0o644 });
192
+
193
+ for (const upload of uploads) {
194
+ const dest = await ingest.copyIn(`raw/${upload.id}`, upload.tempPath, {
195
+ maxBytes: 200 * 1024 * 1024,
196
+ });
197
+ await enqueueProcess(dest);
198
+ }
199
+ ```
200
+
201
+ ### Drop down to `Root` for moves
202
+
203
+ ```ts
204
+ const root = await store.root();
205
+ await root.move(`pending/${id}`, `done/${id}`);
206
+ ```
207
+
208
+ ## See also
209
+
210
+ - [`root()`](root.md) — the boundary `FileStore` is built on; reach for it when you need move/list/append.
211
+ - [JSON store](json-store.md) — the JSON-state-file equivalent of this surface.
212
+ - [Atomic writes](atomic.md) — `writeSiblingTempFile` is what every write goes through.
213
+ - [Temp workspaces](temp.md) — private scratch directories backed by `FileStore`.
Binary file
package/docs/index.md ADDED
@@ -0,0 +1,76 @@
1
+ ---
2
+ title: Overview
3
+ permalink: /
4
+ description: "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths."
5
+ ---
6
+
7
+ # fs-safe
8
+
9
+ Trusted Node.js code that has to touch caller-controlled paths inside a directory it owns gets one boundary it can rely on. `root()` returns a capability-style handle that resolves every relative path against a real directory, refuses anything that escapes it, pins the file you opened, and verifies the write landed where you intended.
10
+
11
+ Think Go's `os.Root` / `OpenInRoot` or Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std), but for Node. `root()` is the product; everything else in this doc set — JSON stores, atomic writes, secret files, archive extraction, temp workspaces — is supporting cast for the same boundary.
12
+
13
+ ## Why
14
+
15
+ `path.resolve(root, input).startsWith(root)` validates a string. It does not pin the file you opened, defend against a symlink retarget between check and use, reject hardlinked aliases, or verify that a write landed where you intended after a rename. `fs-safe` does those things, packaged so every call site picks up the same defense without re-implementing it.
16
+
17
+ This is a **library-level guardrail**, not OS-level isolation. It does not replace containers, seccomp, AppArmor, or filesystem permissions. It is for code that already runs with the privileges of its workspace and wants to stop trivial path tricks from escaping it. Typical fits: agent runtimes, plugin systems, upload extraction, local workspaces, CLIs — anywhere trusted code touches untrusted relative path names.
18
+
19
+ ## Hello world
20
+
21
+ ```ts
22
+ import { root } from "@openclaw/fs-safe";
23
+
24
+ const fs = await root("/safe/workspace", {
25
+ hardlinks: "reject",
26
+ symlinks: "reject",
27
+ mkdir: true,
28
+ });
29
+
30
+ await fs.write("notes/today.txt", "hello\n");
31
+ const text = await fs.readText("notes/today.txt");
32
+ const parsed = await fs.readJson<{ users: string[] }>("config.json");
33
+ await fs.copyIn("uploads/upload.png", "/tmp/upload.png");
34
+ await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true });
35
+ await fs.remove("notes/archive/today.txt");
36
+ ```
37
+
38
+ ## Pick your path
39
+
40
+ - **First time?** [Install](install.md), then walk through the [Quickstart](quickstart.md). Five minutes from `pnpm add` to a working root.
41
+ - **Designing a workspace feature.** Read the [Security model](security-model.md) before you trust the boundary, the [Python helper policy](python-helper.md) before you pick deployment defaults, and the [Errors](errors.md) reference so you know what to catch.
42
+ - **Replacing ad-hoc atomic writes.** Jump to [Atomic writes](atomic.md) or, for keyed JSON state, [JSON files](json.md).
43
+ - **Extracting an upload.** Start at [Archive extraction](archive.md) — handles ZIP and TAR with traversal, link, count, and byte limits.
44
+ - **Running an agent in a sandbox.** [Private temp workspaces](temp.md) plus [secret files](secret-file.md) cover the common scratch-and-credentials shape.
45
+ - **Looking up a name.** Use the [reference](errors.md) section in the sidebar — every public function has a page.
46
+
47
+ ## What you get
48
+
49
+ | Surface | Use it for |
50
+ |---|---|
51
+ | [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
52
+ | [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper configuration (`configureFsSafePython`, `getFsSafePythonConfig`). |
53
+ | [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
54
+ | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
55
+ | [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
56
+ | [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
57
+ | [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. |
58
+ | [`fileStore`](file-store.md) | Managed multi-file/blob store with modes, stream writes, copy-in, pruning, and private mode. |
59
+ | [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
60
+ | [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
61
+ | [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
62
+ | [`walkDirectory`](walk.md) | Budget-bounded recursive directory scan with symlink policy and filters. |
63
+ | [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
64
+ | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
65
+ | [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
66
+ | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and stale-lock recovery. |
67
+ | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
68
+ | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
69
+ | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
70
+ | [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
71
+
72
+ ## Status
73
+
74
+ Currently `0.x` — APIs are stable in shape but may be tightened before `1.0`. The [CHANGELOG](https://github.com/openclaw/fs-safe/blob/main/CHANGELOG.md) tracks visible changes. Issues and PRs at the [GitHub repo](https://github.com/openclaw/fs-safe).
75
+
76
+ Released under the [MIT license](https://github.com/openclaw/fs-safe/blob/main/LICENSE).
@@ -0,0 +1,167 @@
1
+ # Install paths
2
+
3
+ Helpers for code that creates per-name install directories under a trusted base — typical for plugins, packages, snapshots, anywhere you want `<base>/<safe-name>/`. The combination of [`resolveSafeInstallDir`](#resolvesafeinstalldir) and [`assertCanonicalPathWithinBase`](#assertcanonicalpathwithinbase) gives you "compute the install path safely, then re-verify after creation."
4
+
5
+ ```ts
6
+ import {
7
+ assertCanonicalPathWithinBase,
8
+ resolveSafeInstallDir,
9
+ safeDirName,
10
+ safePathSegmentHashed,
11
+ } from "@openclaw/fs-safe/advanced";
12
+ ```
13
+
14
+ ## `resolveSafeInstallDir`
15
+
16
+ ```ts
17
+ function resolveSafeInstallDir(params: {
18
+ baseDir: string;
19
+ id: string;
20
+ invalidNameMessage: string;
21
+ nameEncoder?: (id: string) => string; // default safeDirName
22
+ }): { ok: true; path: string } | { ok: false; error: string };
23
+ ```
24
+
25
+ Computes the absolute install directory for `id` under `baseDir`, after running `id` through `nameEncoder` (`safeDirName` by default). Verifies the result stays inside `baseDir` — anything that would escape returns `{ ok: false, error: invalidNameMessage }`.
26
+
27
+ ```ts
28
+ const r = resolveSafeInstallDir({
29
+ baseDir: "/srv/plugins",
30
+ id: "@scope/my-plugin",
31
+ invalidNameMessage: "invalid plugin name",
32
+ });
33
+ if (!r.ok) return reply(400, r.error);
34
+
35
+ await fs.mkdir(r.path, { recursive: true });
36
+ ```
37
+
38
+ For ids whose default-sanitized form might collide (e.g. `"foo/bar"` and `"foo\\bar"` both map to `"foo__bar"`), pass `nameEncoder: safePathSegmentHashed` to append a content hash:
39
+
40
+ ```ts
41
+ const r = resolveSafeInstallDir({
42
+ baseDir: "/srv/plugins",
43
+ id: untrustedId,
44
+ invalidNameMessage: "invalid plugin name",
45
+ nameEncoder: safePathSegmentHashed,
46
+ });
47
+ ```
48
+
49
+ The helper does **not** create the directory — it returns the path. Pair with `fs.mkdir`, [`Root.mkdir`](root.md), or `assertCanonicalPathWithinBase` before/after creation as needed.
50
+
51
+ ## `assertCanonicalPathWithinBase`
52
+
53
+ Async. Verifies that a candidate absolute path's canonical real path stays inside the base. Useful as a post-`mkdir` check, or when you have an existing path you didn't compute yourself.
54
+
55
+ ```ts
56
+ function assertCanonicalPathWithinBase(params: {
57
+ baseDir: string;
58
+ candidatePath: string;
59
+ boundaryLabel: string;
60
+ }): Promise<void>;
61
+ ```
62
+
63
+ Throws if the candidate resolves outside `baseDir` after `realpath`. The `boundaryLabel` is included in the error message ("Invalid path: must stay within {boundaryLabel}").
64
+
65
+ ```ts
66
+ await assertCanonicalPathWithinBase({
67
+ baseDir: "/srv/plugins",
68
+ candidatePath: "/srv/plugins/my-plugin",
69
+ boundaryLabel: "plugin install dir",
70
+ });
71
+ ```
72
+
73
+ If the candidate does not exist, the helper validates the parent directory instead — useful for "the directory I'm about to create" semantics.
74
+
75
+ ## Segment sanitizers
76
+
77
+ ### `safeDirName`
78
+
79
+ Returns a directory-safe segment derived from `input` by replacing `/` and `\` with `__`. Trims whitespace; returns an empty string if the input was only whitespace.
80
+
81
+ ```ts
82
+ safeDirName("@scope/my-plugin"); // "@scope__my-plugin"
83
+ safeDirName("../../etc"); // "..__..__etc"
84
+ safeDirName("plugin-v1"); // "plugin-v1"
85
+ safeDirName(""); // ""
86
+ ```
87
+
88
+ `safeDirName` does *not* try to be exhaustive about Windows-reserved names or special characters. It is purely a separator-stripping pass — `resolveSafeInstallDir` adds the boundary check on top so an `"../../etc"` input cannot escape `baseDir`.
89
+
90
+ For stricter sanitization, use `safePathSegmentHashed`.
91
+
92
+ ### `safePathSegmentHashed`
93
+
94
+ Returns a directory-safe segment **plus** a short content hash when sanitization changed the input or when the safe form is too long. Use this when input collisions matter:
95
+
96
+ ```ts
97
+ safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
98
+ safePathSegmentHashed("plugin/v1"); // "plugin-v1-3f2a..."
99
+ safePathSegmentHashed("plugin\\v1"); // "plugin-v1-91c4..." (different hash; same safe form)
100
+ safePathSegmentHashed("Über@"); // "ber-9aae..."
101
+ safePathSegmentHashed(""); // "skill" (empty fallback)
102
+ safePathSegmentHashed("."); // "skill"
103
+ ```
104
+
105
+ The sanitization is more aggressive than `safeDirName`: any character not in `[A-Za-z0-9._-]` becomes `-`, runs of `-` collapse, leading and trailing `-` are stripped, the empty/`.`/`..` fallback is `"skill"`. Long results are truncated to 50 chars before the hash is appended.
106
+
107
+ The hash is the first 10 hex chars of `sha256(originalInput)`. It guarantees that two distinct inputs which sanitize to the same string yield distinct outputs.
108
+
109
+ ## Common patterns
110
+
111
+ ### Install a plugin
112
+
113
+ ```ts
114
+ import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashed } from "@openclaw/fs-safe/advanced";
115
+ import { extractArchive } from "@openclaw/fs-safe/archive";
116
+ import fs from "node:fs/promises";
117
+
118
+ const r = resolveSafeInstallDir({
119
+ baseDir: "/srv/plugins",
120
+ id: untrustedName,
121
+ invalidNameMessage: "invalid plugin name",
122
+ nameEncoder: safePathSegmentHashed,
123
+ });
124
+ if (!r.ok) return reply(400, r.error);
125
+
126
+ await fs.mkdir(r.path, { recursive: true, mode: 0o755 });
127
+ await assertCanonicalPathWithinBase({
128
+ baseDir: "/srv/plugins",
129
+ candidatePath: r.path,
130
+ boundaryLabel: "plugin install dir",
131
+ });
132
+
133
+ await extractArchive({
134
+ archivePath: pluginZip,
135
+ destDir: r.path,
136
+ kind: "zip",
137
+ timeoutMs: 30_000,
138
+ });
139
+ ```
140
+
141
+ ### Per-version snapshot directories
142
+
143
+ ```ts
144
+ const snap = resolveSafeInstallDir({
145
+ baseDir: "/srv/snapshots",
146
+ id: `${runId}-${version}`,
147
+ invalidNameMessage: "invalid snapshot id",
148
+ });
149
+ if (!snap.ok) throw new Error(snap.error);
150
+ await fs.mkdir(snap.path, { recursive: true });
151
+ ```
152
+
153
+ ### Reject and log on bad input
154
+
155
+ ```ts
156
+ const r = resolveSafeInstallDir({ baseDir, id, invalidNameMessage: "bad name" });
157
+ if (!r.ok) {
158
+ logger.warn({ id, base: baseDir, error: r.error }, "rejected install attempt");
159
+ return reply(400, r.error);
160
+ }
161
+ ```
162
+
163
+ ## See also
164
+
165
+ - [`root()`](root.md) — when the install dir becomes a root for further writes.
166
+ - [Filenames](filename.md) — `sanitizeUntrustedFileName` for file-name (not directory-name) sanitization.
167
+ - [Archive extraction](archive.md) — extract into the install dir computed by these helpers.