@openclaw/fs-safe 0.2.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +18 -5
- package/dist/advanced.d.ts +1 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/deny-mutations.d.ts +11 -0
- package/dist/deny-mutations.d.ts.map +1 -0
- package/dist/deny-mutations.js +102 -0
- package/dist/device-path.d.ts +13 -0
- package/dist/device-path.d.ts.map +1 -0
- package/dist/device-path.js +112 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/fsync.d.ts +2 -0
- package/dist/fsync.d.ts.map +1 -0
- package/dist/fsync.js +21 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +51 -4
- package/dist/move-path.d.ts +3 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +13 -1
- package/dist/opened-realpath.d.ts +3 -0
- package/dist/opened-realpath.d.ts.map +1 -0
- package/dist/opened-realpath.js +79 -0
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -0
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +7 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +3 -0
- package/dist/read-opened-file.d.ts +18 -0
- package/dist/read-opened-file.d.ts.map +1 -0
- package/dist/read-opened-file.js +15 -0
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +26 -3
- package/dist/root-impl.d.ts +18 -15
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +110 -147
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +2 -0
- package/dist/walk.d.ts +13 -2
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +29 -6
- package/docs/contributing.md +1 -1
- package/docs/errors.md +5 -0
- package/docs/install.md +3 -3
- package/docs/path.md +13 -0
- package/docs/reading.md +6 -4
- package/docs/root.md +15 -5
- package/docs/security-model.md +7 -2
- package/docs/types.md +22 -9
- package/docs/walk.md +11 -1
- package/docs/writing.md +21 -3
- package/package.json +22 -17
package/docs/install.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Install
|
|
2
2
|
|
|
3
|
-
`fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node
|
|
3
|
+
`fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node 22 or newer, ships ESM only, and works on macOS, Linux, and Windows.
|
|
4
4
|
|
|
5
5
|
## Package managers
|
|
6
6
|
|
|
@@ -22,13 +22,13 @@ bun add @openclaw/fs-safe
|
|
|
22
22
|
|
|
23
23
|
## Node version
|
|
24
24
|
|
|
25
|
-
Minimum **Node
|
|
25
|
+
Minimum **Node 22**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
|
|
26
26
|
|
|
27
27
|
Verify the runtime:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
node --version
|
|
31
|
-
#
|
|
31
|
+
# v22.0.0 or newer
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## TypeScript
|
package/docs/path.md
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
safeRealpathSync,
|
|
12
12
|
safeStatSync,
|
|
13
13
|
assertNoNulPathInput,
|
|
14
|
+
assertNoUnsafeDeviceReadPath,
|
|
15
|
+
isUnsafeDeviceReadPath,
|
|
14
16
|
isNotFoundPathError,
|
|
15
17
|
isSymlinkOpenError,
|
|
16
18
|
hasNodeErrorCode,
|
|
@@ -89,6 +91,17 @@ if (!stat?.isFile()) return notFound();
|
|
|
89
91
|
|
|
90
92
|
Throws `FsSafeError` with code `invalid-path` when a path string contains an embedded NUL byte. Use it before calling Node `fs` APIs directly; Node's native error can include raw path text in the message.
|
|
91
93
|
|
|
94
|
+
### `assertNoUnsafeDeviceReadPath(filePath, options?)`
|
|
95
|
+
|
|
96
|
+
Throws `FsSafeError` with code `device-path` when a read target is a known unsafe device or process-fd path. The built-in read/open helpers call this automatically before opening files; use it only when you are building your own read primitive.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
assertNoUnsafeDeviceReadPath("/dev/zero"); // throws on POSIX
|
|
100
|
+
isUnsafeDeviceReadPath("/dev/fd/0"); // true on POSIX
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The check is intentionally not a normal consumer policy knob. Safe read APIs reject these targets by default because they can block forever, stream indefinitely, or alias process file descriptors.
|
|
104
|
+
|
|
92
105
|
## Error inspection
|
|
93
106
|
|
|
94
107
|
### `isNotFoundPathError(err)`
|
package/docs/reading.md
CHANGED
|
@@ -17,10 +17,11 @@ Regardless of shape, every read goes through the same boundary checks:
|
|
|
17
17
|
1. Resolve the relative path against the canonical real root.
|
|
18
18
|
2. Reject anything that escapes the root (`outside-workspace`).
|
|
19
19
|
3. Reject `..` segments and absolute inputs (unless via `readAbsolute` with an in-root absolute path).
|
|
20
|
-
4.
|
|
21
|
-
5.
|
|
22
|
-
6.
|
|
23
|
-
7. If `
|
|
20
|
+
4. Reject known unsafe device and process-fd paths before opening (`device-path`).
|
|
21
|
+
5. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
|
|
22
|
+
6. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
|
|
23
|
+
7. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
|
|
24
|
+
8. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
|
|
24
25
|
|
|
25
26
|
## Read shapes
|
|
26
27
|
|
|
@@ -160,6 +161,7 @@ try {
|
|
|
160
161
|
- **`outside-workspace`** — relative path escaped the root, or `readAbsolute` got an absolute path outside.
|
|
161
162
|
- **`not-found`** — the file is gone.
|
|
162
163
|
- **`not-file`** — you read a directory or a non-regular file (FIFO, socket, …).
|
|
164
|
+
- **`device-path`** — the path targets a known unsafe device or process fd path.
|
|
163
165
|
- **`symlink`** — a path component is a symlink and the policy is `reject`.
|
|
164
166
|
- **`path-mismatch`** — opened fd identity did not match the resolved path. Almost always a TOCTOU swap by something else.
|
|
165
167
|
- **`hardlink`** — `hardlinks: "reject"` saw `nlink > 1`.
|
package/docs/root.md
CHANGED
|
@@ -19,17 +19,23 @@ function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
|
19
19
|
|
|
20
20
|
type RootDefaults = {
|
|
21
21
|
hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
|
|
22
|
+
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
22
23
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
23
24
|
mkdir?: boolean; // create missing parent dirs on write/openWritable/append
|
|
24
25
|
mode?: number; // file mode applied to new writes; per-call override available
|
|
25
26
|
nonBlockingRead?: boolean; // schedule reads on a worker; useful for large files
|
|
26
27
|
symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
|
|
27
28
|
};
|
|
29
|
+
|
|
30
|
+
type DenyMutationPolicy = {
|
|
31
|
+
paths?: readonly string[];
|
|
32
|
+
prefixes?: readonly string[];
|
|
33
|
+
};
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
`root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
|
|
31
37
|
|
|
32
|
-
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.
|
|
38
|
+
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
|
|
33
39
|
|
|
34
40
|
## The `Root` interface
|
|
35
41
|
|
|
@@ -65,13 +71,13 @@ fs.write(rel, data, options?) // overwrite-ok atomic write
|
|
|
65
71
|
fs.create(rel, data, options?) // throws "already-exists" if target exists
|
|
66
72
|
fs.writeJson(rel, value, options?) // JSON.stringify + atomic write
|
|
67
73
|
fs.createJson(rel, value, options?) // create() variant of writeJson
|
|
68
|
-
fs.append(rel, data, options?) // append text/buffer;
|
|
74
|
+
fs.append(rel, data, options?) // append text/buffer; syncs before close
|
|
69
75
|
fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
|
|
70
76
|
fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
|
|
71
77
|
fs.move(from, to, options?) // rename within the root; defaults to no clobber
|
|
72
|
-
fs.remove(rel)
|
|
73
|
-
fs.mkdir(rel)
|
|
74
|
-
fs.ensureRoot()
|
|
78
|
+
fs.remove(rel, options?) // unlink file or rmdir empty directory
|
|
79
|
+
fs.mkdir(rel, options?) // mkdir -p (creates missing parents)
|
|
80
|
+
fs.ensureRoot(options?) // accepts "" / "." as the root itself
|
|
75
81
|
```
|
|
76
82
|
|
|
77
83
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
@@ -80,6 +86,8 @@ fs.ensureRoot() // accepts "" / "." as the root itself
|
|
|
80
86
|
|
|
81
87
|
`openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
|
|
82
88
|
|
|
89
|
+
All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
|
|
90
|
+
|
|
83
91
|
### Inspection (advisory)
|
|
84
92
|
|
|
85
93
|
```ts
|
|
@@ -131,7 +139,9 @@ Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not messa
|
|
|
131
139
|
| `outside-workspace` | The input resolves outside the root, or contains a `..` segment that would escape it. |
|
|
132
140
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
|
|
133
141
|
| `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
|
|
142
|
+
| `device-path` | A read/open target is a known unsafe device or process-fd path. |
|
|
134
143
|
| `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
|
|
144
|
+
| `denied-path` | A mutation target matched `denyMutations.paths` or `denyMutations.prefixes`. |
|
|
135
145
|
| `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
|
|
136
146
|
| `hardlink` | The target's `nlink > 1` and `hardlinks` policy is `reject`. |
|
|
137
147
|
| `path-mismatch` | Post-open identity check failed — the opened fd does not match the resolved path. |
|
package/docs/security-model.md
CHANGED
|
@@ -13,6 +13,7 @@ You hand a `root()` boundary to a piece of code that takes caller-controlled rel
|
|
|
13
13
|
- replaces a path component with a symlink between check and use (TOCTOU)
|
|
14
14
|
- replaces the destination directory with a symlink right before a write
|
|
15
15
|
- creates a hardlink that aliases an out-of-tree inode and asks you to read or replace it
|
|
16
|
+
- asks a read/open primitive to target a known unsafe device or process-fd path
|
|
16
17
|
- triggers a partial write that leaves a half-written file at the destination
|
|
17
18
|
- ships an archive with `..` paths, absolute paths, or symlinks pointing outside the destination
|
|
18
19
|
|
|
@@ -20,7 +21,7 @@ It does **not** defend against:
|
|
|
20
21
|
|
|
21
22
|
- a process running with permissions to write anywhere on the filesystem and choosing to ignore the library
|
|
22
23
|
- another process with the same UID racing to mutate the same directory between two separate `fs-safe` calls — the boundary is per-call, not per-session
|
|
23
|
-
- traversal across filesystem boundaries, bind mounts,
|
|
24
|
+
- arbitrary traversal across filesystem boundaries, bind mounts, or virtual filesystems beyond the known unsafe read device paths
|
|
24
25
|
- container escape, TOCTOU between fork and exec of helpers, or kernel-level vulnerabilities
|
|
25
26
|
- semantic content checks: file types, archive payload schemas, signature verification
|
|
26
27
|
|
|
@@ -50,6 +51,10 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
|
|
|
50
51
|
|
|
51
52
|
`resolve()`, `exists()`, `stat()`, and `list()` are explicitly **not** race-resistant — they answer a question and return. To act on a path with race resistance, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. They re-pin the path identity at the point of use.
|
|
52
53
|
|
|
54
|
+
### Denied mutations
|
|
55
|
+
|
|
56
|
+
`denyMutations` is an opt-in application policy for `root()` mutation methods. It blocks exact absolute paths with `paths` and whole subtrees with `prefixes`, merging root defaults with per-call entries so a call cannot clear root-level denies. This is not an OS permission boundary: code with access to `node:fs`, a shell, or another process with the same filesystem privileges can bypass it.
|
|
57
|
+
|
|
53
58
|
### Atomic writes
|
|
54
59
|
|
|
55
60
|
`replaceFileAtomic` writes to a sibling temp file in the destination directory, optionally `fsync`s it, optionally `fsync`s the parent directory after rename, and atomically renames over the destination. On failure mid-write, the destination is either the old contents (rename never happened) or the new contents (rename succeeded). There is no half-written intermediate state visible at the destination path.
|
|
@@ -79,7 +84,7 @@ The library does not advertise different security guarantees per platform — it
|
|
|
79
84
|
|---|---|
|
|
80
85
|
| Not ambient authority removal | Code that can import `node:fs` can still bypass the handle. Keep caller-controlled path operations behind `root()` by convention, review, and tests. |
|
|
81
86
|
| Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
|
|
82
|
-
| Not a mount
|
|
87
|
+
| Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
|
|
83
88
|
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between two separate calls. Use one verb method for the operation you need to make race-resistant. |
|
|
84
89
|
| Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
|
|
85
90
|
| Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
|
package/docs/types.md
CHANGED
|
@@ -82,6 +82,7 @@ type ReadResult = {
|
|
|
82
82
|
|
|
83
83
|
```ts
|
|
84
84
|
type RootDefaults = {
|
|
85
|
+
denyMutations?: DenyMutationPolicy;
|
|
85
86
|
hardlinks?: "reject" | "allow";
|
|
86
87
|
maxBytes?: number;
|
|
87
88
|
mkdir?: boolean;
|
|
@@ -90,26 +91,31 @@ type RootDefaults = {
|
|
|
90
91
|
symlinks?: "reject" | "follow-within-root";
|
|
91
92
|
};
|
|
92
93
|
|
|
94
|
+
type DenyMutationPolicy = {
|
|
95
|
+
paths?: readonly string[];
|
|
96
|
+
prefixes?: readonly string[];
|
|
97
|
+
};
|
|
98
|
+
|
|
93
99
|
type RootOptions = {
|
|
94
100
|
rootDir: string;
|
|
95
101
|
defaults?: RootDefaults;
|
|
96
102
|
};
|
|
97
103
|
```
|
|
98
104
|
|
|
99
|
-
`RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these.
|
|
105
|
+
`RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` is the exception: root and per-call deny entries are merged.
|
|
100
106
|
|
|
101
107
|
## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
|
|
102
108
|
|
|
103
109
|
```ts
|
|
104
110
|
type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
|
|
105
|
-
type RootWriteOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
|
|
111
|
+
type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
106
112
|
encoding?: BufferEncoding;
|
|
107
113
|
overwrite?: boolean;
|
|
108
114
|
};
|
|
109
|
-
type RootCopyOptions = Pick<RootDefaults, "maxBytes" | "mkdir" | "mode"> & {
|
|
115
|
+
type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
|
|
110
116
|
sourceHardlinks?: "reject" | "allow";
|
|
111
117
|
};
|
|
112
|
-
type RootOpenWritableOptions = Pick<RootDefaults, "mkdir" | "mode"> & {
|
|
118
|
+
type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
113
119
|
writeMode?: "replace" | "append" | "update";
|
|
114
120
|
};
|
|
115
121
|
type RootWriteJsonOptions = RootWriteOptions & {
|
|
@@ -120,6 +126,11 @@ type RootWriteJsonOptions = RootWriteOptions & {
|
|
|
120
126
|
type RootAppendOptions = RootWriteOptions & {
|
|
121
127
|
prependNewlineIfNeeded?: boolean;
|
|
122
128
|
};
|
|
129
|
+
type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
|
|
130
|
+
overwrite?: boolean;
|
|
131
|
+
};
|
|
132
|
+
type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
|
|
133
|
+
type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
|
|
123
134
|
```
|
|
124
135
|
|
|
125
136
|
Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
|
|
@@ -137,11 +148,13 @@ The two policy unions you'll see throughout. `"reject"` is conservative; `"follo
|
|
|
137
148
|
|
|
138
149
|
```ts
|
|
139
150
|
type FsSafeErrorCode =
|
|
140
|
-
| "already-exists" | "
|
|
141
|
-
| "
|
|
142
|
-
| "
|
|
143
|
-
| "
|
|
144
|
-
| "
|
|
151
|
+
| "already-exists" | "denied-path" | "device-path" | "hardlink"
|
|
152
|
+
| "helper-failed"
|
|
153
|
+
| "helper-unavailable" | "insecure-permissions" | "invalid-path"
|
|
154
|
+
| "not-empty" | "not-file" | "not-found" | "not-owned"
|
|
155
|
+
| "not-removable" | "outside-workspace" | "path-alias"
|
|
156
|
+
| "path-mismatch" | "permission-unverified" | "symlink"
|
|
157
|
+
| "timeout" | "too-large" | "unsupported-platform";
|
|
145
158
|
```
|
|
146
159
|
|
|
147
160
|
Closed union you switch on. See the [Errors](errors.md) reference for what each one means.
|
package/docs/walk.md
CHANGED
|
@@ -25,6 +25,7 @@ type WalkDirectoryResult = {
|
|
|
25
25
|
entries: WalkDirectoryEntry[];
|
|
26
26
|
scannedEntryCount: number;
|
|
27
27
|
truncated: boolean;
|
|
28
|
+
failedDirs?: WalkDirectoryFailure[];
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
type WalkDirectoryEntry = {
|
|
@@ -35,10 +36,19 @@ type WalkDirectoryEntry = {
|
|
|
35
36
|
kind: "file" | "directory" | "symlink" | "other";
|
|
36
37
|
dirent: import("node:fs").Dirent;
|
|
37
38
|
};
|
|
39
|
+
|
|
40
|
+
type WalkDirectoryFailure = {
|
|
41
|
+
path: string;
|
|
42
|
+
relativePath: string;
|
|
43
|
+
depth: number;
|
|
44
|
+
error: unknown;
|
|
45
|
+
};
|
|
38
46
|
```
|
|
39
47
|
|
|
40
48
|
`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
49
|
|
|
50
|
+
`walkDirectory()` and `walkDirectorySync()` always return `failedDirs`; the property remains optional on the exported `WalkDirectoryResult` type so existing callers that manually construct the legacy result shape remain source-compatible. It lists every directory whose `realpath`/`readdir` threw, so its contents are absent from `entries`. `error` is the thrown value (a `NodeJS.ErrnoException` at runtime), so callers can distinguish a benign missing-directory race (`ENOENT`) from a real read failure (`EACCES`, `EIO`, `ESTALE`, …). The walk-root failure has an empty `relativePath` and `depth: 0`. Failures resolving a symlink's target kind are not reported here.
|
|
51
|
+
|
|
42
52
|
## Options
|
|
43
53
|
|
|
44
54
|
```ts
|
|
@@ -55,7 +65,7 @@ type WalkDirectoryOptions = {
|
|
|
55
65
|
|
|
56
66
|
`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
67
|
|
|
58
|
-
Unreadable directories are skipped
|
|
68
|
+
Unreadable directories are skipped rather than throwing, but every skipped directory is recorded in `failedDirs`. This keeps the helper suitable for best-effort inventories while letting pruning jobs tell an incomplete scan from an empty one: a destructive reconcile that deletes state for paths missing from `entries` must first confirm `failedDirs` holds no real read failures, or a transient `EIO`/`EACCES` blip would be mistaken for mass deletion. Use a stricter root-bounded operation when every entry must be accounted for.
|
|
59
69
|
|
|
60
70
|
## See also
|
|
61
71
|
|
package/docs/writing.md
CHANGED
|
@@ -24,6 +24,24 @@ await fs.mkdir("snapshots/2026/05");
|
|
|
24
24
|
|
|
25
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
26
|
|
|
27
|
+
## Denying mutations
|
|
28
|
+
|
|
29
|
+
All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const fs = await root("/srv/workspace", {
|
|
33
|
+
denyMutations: {
|
|
34
|
+
paths: ["/srv/workspace/.env"],
|
|
35
|
+
prefixes: ["/srv/workspace/.ssh"],
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await fs.write(".env", "x"); // throws FsSafeError code "denied-path"
|
|
40
|
+
await fs.remove(".ssh/id_rsa"); // throws FsSafeError code "denied-path"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`paths` blocks exact absolute paths. `prefixes` blocks absolute paths and everything below them. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults.
|
|
44
|
+
|
|
27
45
|
## Write verbs
|
|
28
46
|
|
|
29
47
|
### `fs.write(rel, data, options?)`
|
|
@@ -35,7 +53,7 @@ await fs.write("state/last-run.json", JSON.stringify(run));
|
|
|
35
53
|
await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
|
|
36
54
|
```
|
|
37
55
|
|
|
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()`.
|
|
56
|
+
`data` accepts `string | Buffer`. `options` are `{ denyMutations?: DenyMutationPolicy; 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
57
|
|
|
40
58
|
### `fs.create(rel, data, options?)`
|
|
41
59
|
|
|
@@ -75,14 +93,14 @@ type RootWriteJsonOptions = {
|
|
|
75
93
|
|
|
76
94
|
### `fs.append(rel, data, options?)`
|
|
77
95
|
|
|
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.
|
|
96
|
+
Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
|
|
79
97
|
|
|
80
98
|
```ts
|
|
81
99
|
await fs.append("logs/today.log", `[${ts}] ${line}\n`);
|
|
82
100
|
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
|
|
83
101
|
```
|
|
84
102
|
|
|
85
|
-
For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle.
|
|
103
|
+
For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle. Direct append-mode writes preserve kernel append semantics, but they are not atomic against external rotators that rename or unlink the target.
|
|
86
104
|
|
|
87
105
|
### `fs.copyIn(rel, sourceAbsPath, options?)`
|
|
88
106
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -97,29 +97,34 @@
|
|
|
97
97
|
"default": "./dist/test-hooks.js"
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
|
+
"optionalDependencies": {
|
|
101
|
+
"jszip": "^3.10.1",
|
|
102
|
+
"tar": "7.5.16"
|
|
103
|
+
},
|
|
104
|
+
"devDependencies": {
|
|
105
|
+
"@types/node": "^22.19.20",
|
|
106
|
+
"@vitest/coverage-v8": "4.1.8",
|
|
107
|
+
"typescript": "^5.9.3",
|
|
108
|
+
"vitest": "^4.1.8"
|
|
109
|
+
},
|
|
110
|
+
"engines": {
|
|
111
|
+
"node": ">=22"
|
|
112
|
+
},
|
|
100
113
|
"scripts": {
|
|
101
114
|
"benchmark": "node scripts/benchmark.mjs",
|
|
102
115
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
103
116
|
"lint:file-size": "node scripts/check-file-size.mjs",
|
|
104
117
|
"lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
|
|
105
|
-
"prepack": "node scripts/prepack-build.mjs",
|
|
106
118
|
"test": "vitest run",
|
|
107
119
|
"test:coverage": "vitest run --coverage",
|
|
108
120
|
"test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
|
|
109
121
|
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
|
|
110
|
-
"docs:site": "node scripts/build-docs-site.mjs"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"@types/node": "^22.15.19",
|
|
118
|
-
"@vitest/coverage-v8": "4.1.6",
|
|
119
|
-
"typescript": "^5.8.3",
|
|
120
|
-
"vitest": "^4.1.6"
|
|
121
|
-
},
|
|
122
|
-
"engines": {
|
|
123
|
-
"node": ">=20.11"
|
|
122
|
+
"docs:site": "node scripts/build-docs-site.mjs",
|
|
123
|
+
"check:changed": "pnpm run check",
|
|
124
|
+
"test:changed": "pnpm run test",
|
|
125
|
+
"crabbox:hydrate": "crabbox actions hydrate",
|
|
126
|
+
"crabbox:run": "crabbox run",
|
|
127
|
+
"crabbox:stop": "crabbox stop",
|
|
128
|
+
"crabbox:warmup": "crabbox warmup"
|
|
124
129
|
}
|
|
125
|
-
}
|
|
130
|
+
}
|