@openclaw/fs-safe 0.4.0 → 0.4.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.
- package/CHANGELOG.md +25 -0
- package/README.md +4 -2
- package/dist/advanced.d.ts +1 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/async-lock.d.ts.map +1 -1
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.d.ts.map +1 -0
- package/dist/bounded-read.js +86 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/file-identity.d.ts +1 -0
- package/dist/file-identity.d.ts.map +1 -1
- package/dist/file-identity.js +10 -2
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +5 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/json.d.ts +8 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +27 -14
- package/dist/pinned-python.js +10 -10
- package/dist/pinned-write.d.ts +9 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +88 -5
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +4 -4
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +11 -52
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-impl.d.ts +4 -1
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +10 -7
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +5 -2
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +6 -7
- package/dist/sidecar-lock.d.ts +5 -1
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +3 -42
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/docs/advanced.md +20 -0
- package/docs/config.md +2 -2
- package/docs/index.md +1 -1
- package/docs/json-store.md +2 -2
- package/docs/json.md +13 -5
- package/docs/regular-file.md +26 -32
- package/docs/security-model.md +1 -0
- package/docs/sidecar-lock.md +8 -24
- package/docs/writing.md +21 -1
- package/package.json +19 -16
package/docs/advanced.md
CHANGED
|
@@ -55,6 +55,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
55
55
|
|
|
56
56
|
| Export | Page | Notes |
|
|
57
57
|
|---|---|---|
|
|
58
|
+
| `readFileDescriptorBounded`, `readFileDescriptorBoundedSync`, `readFileHandleBounded` | – | Incremental whole-file reads for already-open descriptors/handles. They consume at most `maxBytes + 1`, do not close the input, and throw `FsSafeError("too-large")` on overflow. |
|
|
58
59
|
| `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level no-follow open routed through the root-file path. |
|
|
59
60
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
60
61
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
@@ -62,6 +63,25 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
62
63
|
| `assertNoSymlinkParents`, `assertNoSymlinkParentsSync`, `AssertNoSymlinkParentsOptions` | – | Reject paths whose ancestor chain contains symlinks. |
|
|
63
64
|
| `assertNoHardlinkedFinalPath`, `assertNoPathAliasEscape`, `PATH_ALIAS_POLICIES`, `PathAliasPolicy` | – | Hardlink/alias defense building blocks. |
|
|
64
65
|
|
|
66
|
+
The bounded descriptor helpers start at the descriptor's current offset and
|
|
67
|
+
leave ownership with the caller. They are intended for the second half of a
|
|
68
|
+
safe read: first open and validate the path using the boundary appropriate to
|
|
69
|
+
your application, then read the already-pinned descriptor without trusting a
|
|
70
|
+
possibly stale size check.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import fs from "node:fs";
|
|
74
|
+
import { readFileDescriptorBoundedSync } from "@openclaw/fs-safe/advanced";
|
|
75
|
+
|
|
76
|
+
const fd = fs.openSync(filePath, "r");
|
|
77
|
+
try {
|
|
78
|
+
const bytes = readFileDescriptorBoundedSync(fd, 256 * 1024);
|
|
79
|
+
consume(bytes);
|
|
80
|
+
} finally {
|
|
81
|
+
fs.closeSync(fd);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
65
85
|
### Local roots and file URLs
|
|
66
86
|
|
|
67
87
|
| Export | Page | Notes |
|
package/docs/config.md
CHANGED
|
@@ -56,7 +56,7 @@ Return the effective configuration: programmatic overrides win, then env vars, t
|
|
|
56
56
|
function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
|
57
57
|
|
|
58
58
|
type FsSafeLockConfig = {
|
|
59
|
-
staleRecovery: "fail-closed" | "remove-if-unchanged";
|
|
59
|
+
staleRecovery: "fail-closed" | "remove-if-unchanged"; // legacy value also fails closed
|
|
60
60
|
staleMs?: number;
|
|
61
61
|
timeoutMs?: number;
|
|
62
62
|
retry?: FileLockRetryOptions;
|
|
@@ -65,7 +65,7 @@ type FsSafeLockConfig = {
|
|
|
65
65
|
|
|
66
66
|
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
|
67
67
|
|
|
68
|
-
`staleRecovery` defaults to `"fail-closed"`. `"remove-if-unchanged"`
|
|
68
|
+
`staleRecovery` defaults to `"fail-closed"`. The deprecated `"remove-if-unchanged"` value remains accepted for source and configuration compatibility, but behaves as `"fail-closed"`. fs-safe never removes a stale third-party lock during acquisition because a pathname recheck followed by unlink cannot prevent deleting a replacement lock.
|
|
69
69
|
|
|
70
70
|
## `getFsSafeLockConfig()`
|
|
71
71
|
|
package/docs/index.md
CHANGED
|
@@ -64,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
64
64
|
| [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
|
|
65
65
|
| [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
|
|
66
66
|
| [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
|
|
67
|
-
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry
|
|
67
|
+
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
|
|
68
68
|
| [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
|
|
69
69
|
| [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
|
|
70
70
|
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
package/docs/json-store.md
CHANGED
|
@@ -53,7 +53,7 @@ type JsonStoreLockOptions = {
|
|
|
53
53
|
staleMs?: number; // default 30_000
|
|
54
54
|
timeoutMs?: number; // default 30_000
|
|
55
55
|
retry?: FileLockRetryOptions;
|
|
56
|
-
staleRecovery?: "fail-closed" | "remove-if-unchanged";
|
|
56
|
+
staleRecovery?: "fail-closed" | "remove-if-unchanged"; // legacy value also fails closed
|
|
57
57
|
managerKey?: string; // default `fs-safe.json-store:<filePath>`
|
|
58
58
|
};
|
|
59
59
|
|
|
@@ -141,7 +141,7 @@ When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` sha
|
|
|
141
141
|
|
|
142
142
|
Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
|
|
143
143
|
|
|
144
|
-
JSON store locks
|
|
144
|
+
JSON store locks always fail closed on stale sidecars. The deprecated `staleRecovery: "remove-if-unchanged"` value remains accepted for compatibility, but it behaves as `"fail-closed"` and never removes the lock.
|
|
145
145
|
|
|
146
146
|
The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
|
|
147
147
|
|
package/docs/json.md
CHANGED
|
@@ -40,15 +40,22 @@ Use `readJson` when missing-or-malformed is a programmer error you want to surfa
|
|
|
40
40
|
|
|
41
41
|
## Reading
|
|
42
42
|
|
|
43
|
-
### `readJson<T>(filePath)`
|
|
43
|
+
### `readJson<T>(filePath, options?)`
|
|
44
44
|
|
|
45
45
|
Async strict reader. Throws `JsonFileReadError` on missing or invalid input. The cast is unchecked — validate the shape with your own schema (zod, valibot, …) if it came from an untrusted source.
|
|
46
46
|
|
|
47
47
|
```ts
|
|
48
48
|
const manifest = await readJson<Manifest>("./manifest.json");
|
|
49
|
+
const smallManifest = await readJson<Manifest>("./manifest.json", {
|
|
50
|
+
maxBytes: 256 * 1024,
|
|
51
|
+
});
|
|
49
52
|
```
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
All standalone readers accept `{ maxBytes?: number }`. When set, the read is
|
|
55
|
+
incremental and consumes no more than `maxBytes + 1` bytes before rejecting, so
|
|
56
|
+
file growth after the initial stat cannot cause an unbounded allocation.
|
|
57
|
+
|
|
58
|
+
### `readJsonIfExists<T>(filePath, options?)`
|
|
52
59
|
|
|
53
60
|
Async semi-lenient reader. Returns `null` if the file is missing; throws `JsonFileReadError` if the file exists but cannot be parsed.
|
|
54
61
|
|
|
@@ -56,7 +63,7 @@ Async semi-lenient reader. Returns `null` if the file is missing; throws `JsonFi
|
|
|
56
63
|
const cache = (await readJsonIfExists<Cache>("./cache.json")) ?? freshCache();
|
|
57
64
|
```
|
|
58
65
|
|
|
59
|
-
### `tryReadJson<T>(filePath)`
|
|
66
|
+
### `tryReadJson<T>(filePath, options?)`
|
|
60
67
|
|
|
61
68
|
Async lenient reader. Returns `null` for any failure (missing, unreadable, invalid). The "no fuss" sibling.
|
|
62
69
|
|
|
@@ -64,11 +71,11 @@ Async lenient reader. Returns `null` for any failure (missing, unreadable, inval
|
|
|
64
71
|
const optional = (await tryReadJson<Settings>("./settings.json")) ?? defaults;
|
|
65
72
|
```
|
|
66
73
|
|
|
67
|
-
### `readJsonSync<T>(filePath)`
|
|
74
|
+
### `readJsonSync<T>(filePath, options?)`
|
|
68
75
|
|
|
69
76
|
Synchronous strict reader. Throws `JsonFileReadError` on missing or invalid input, matching the async `readJson` contract.
|
|
70
77
|
|
|
71
|
-
### `tryReadJsonSync<T>(pathname)`
|
|
78
|
+
### `tryReadJsonSync<T>(pathname, options?)`
|
|
72
79
|
|
|
73
80
|
Synchronous, generic, lenient. Returns `T | null`. Useful in boot paths where you want a typed result without async.
|
|
74
81
|
|
|
@@ -83,6 +90,7 @@ const result = readRootJsonObjectSync({
|
|
|
83
90
|
rootDir: "/safe/workspace",
|
|
84
91
|
relativePath: "plugin/openclaw.plugin.json",
|
|
85
92
|
boundaryLabel: "plugin manifest",
|
|
93
|
+
maxBytes: 256 * 1024,
|
|
86
94
|
});
|
|
87
95
|
|
|
88
96
|
if (!result.ok) {
|
package/docs/regular-file.md
CHANGED
|
@@ -32,14 +32,14 @@ type RegularFileStatResult =
|
|
|
32
32
|
| { missing: false; stat: Stats };
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
A non-regular file (directory, FIFO, …)
|
|
35
|
+
A non-regular file (directory, FIFO, symlink, …) throws. Missing paths return
|
|
36
|
+
`{ missing: true }`; existing regular files return `{ missing: false, stat }`.
|
|
36
37
|
|
|
37
38
|
```ts
|
|
38
39
|
import { statRegularFile } from "@openclaw/fs-safe/advanced";
|
|
39
40
|
|
|
40
41
|
const r = await statRegularFile("/var/log/app.log");
|
|
41
42
|
if (r.missing) return;
|
|
42
|
-
if (!r.stat.isFile()) throw new Error("expected a regular file");
|
|
43
43
|
console.log(`size=${r.stat.size}`);
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -60,19 +60,11 @@ const result = await readRegularFile({
|
|
|
60
60
|
filePath: "/var/log/app.log",
|
|
61
61
|
maxBytes: 4 * 1024 * 1024,
|
|
62
62
|
});
|
|
63
|
-
if (result.missing) return null;
|
|
64
|
-
if (!result.regular) throw new Error("not a regular file");
|
|
65
63
|
processLog(result.buffer);
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
type Result =
|
|
72
|
-
| { missing: true }
|
|
73
|
-
| { missing: false; regular: false; stat: Stats }
|
|
74
|
-
| { missing: false; regular: true; stat: Stats; buffer: Buffer };
|
|
75
|
-
```
|
|
66
|
+
The result is `{ buffer, stat }`. Missing files preserve the normal `ENOENT`
|
|
67
|
+
shape; non-regular targets throw.
|
|
76
68
|
|
|
77
69
|
Throws `FsSafeError` with code `too-large` if the file exceeds `maxBytes`. Other I/O errors propagate as `NodeJS.ErrnoException`.
|
|
78
70
|
|
|
@@ -91,9 +83,8 @@ import { appendRegularFile } from "@openclaw/fs-safe/advanced";
|
|
|
91
83
|
|
|
92
84
|
await appendRegularFile({
|
|
93
85
|
filePath: "/var/log/app.log",
|
|
94
|
-
|
|
86
|
+
content: `[${new Date().toISOString()}] ${line}\n`,
|
|
95
87
|
encoding: "utf8",
|
|
96
|
-
prependNewlineIfNeeded: true,
|
|
97
88
|
});
|
|
98
89
|
```
|
|
99
90
|
|
|
@@ -102,28 +93,30 @@ await appendRegularFile({
|
|
|
102
93
|
```ts
|
|
103
94
|
type AppendRegularFileOptions = {
|
|
104
95
|
filePath: string;
|
|
105
|
-
|
|
106
|
-
encoding?: BufferEncoding;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
96
|
+
content: string | Uint8Array;
|
|
97
|
+
encoding?: BufferEncoding; // default utf8 when content is string
|
|
98
|
+
maxFileBytes?: number; // skip if the resulting file would exceed this
|
|
99
|
+
mode?: number; // default 0o600
|
|
100
|
+
rejectSymlinkParents?: boolean;
|
|
110
101
|
};
|
|
111
102
|
```
|
|
112
103
|
|
|
113
|
-
|
|
104
|
+
The helper refuses symlink and hardlinked final targets. With
|
|
105
|
+
`rejectSymlinkParents: true`, it also rejects symlinked ancestor directories.
|
|
114
106
|
|
|
115
107
|
### `appendRegularFileSync(options)`
|
|
116
108
|
|
|
117
109
|
Synchronous. Same options.
|
|
118
110
|
|
|
119
|
-
### `resolveRegularFileAppendFlags(
|
|
111
|
+
### `resolveRegularFileAppendFlags()`
|
|
120
112
|
|
|
121
|
-
Helper that returns the
|
|
113
|
+
Helper that returns the append helpers' `O_WRONLY | O_APPEND | O_CREAT` flags,
|
|
114
|
+
plus `O_NOFOLLOW` where the platform provides it:
|
|
122
115
|
|
|
123
116
|
```ts
|
|
124
117
|
import { resolveRegularFileAppendFlags } from "@openclaw/fs-safe/advanced";
|
|
125
118
|
|
|
126
|
-
const flags = resolveRegularFileAppendFlags(
|
|
119
|
+
const flags = resolveRegularFileAppendFlags();
|
|
127
120
|
```
|
|
128
121
|
|
|
129
122
|
## Difference from `Root` methods
|
|
@@ -131,9 +124,9 @@ const flags = resolveRegularFileAppendFlags(true, false); // O_WRONLY | O_APPEND
|
|
|
131
124
|
| `regular-file` | `Root` |
|
|
132
125
|
|---|---|
|
|
133
126
|
| Absolute paths only. | Relative to the root. |
|
|
134
|
-
|
|
|
127
|
+
| Verifies path and descriptor identity around reads. | Enforces the same checks within a trusted root. |
|
|
135
128
|
| Caller must be confident the path is trusted. | Boundary check is automatic. |
|
|
136
|
-
|
|
|
129
|
+
| Stat reports missing explicitly; reads throw on missing/non-file. | Throws `FsSafeError` with `code`. |
|
|
137
130
|
|
|
138
131
|
If your call site already trusts the path (it came from your own config, not a caller), `regular-file` is a thinner, faster surface. If the path is caller-influenced, prefer `root()` or wrap in [`pathScope()`](path-scope.md).
|
|
139
132
|
|
|
@@ -142,13 +135,15 @@ If your call site already trusts the path (it came from your own config, not a c
|
|
|
142
135
|
### Read a config file if it's there, else seed
|
|
143
136
|
|
|
144
137
|
```ts
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
138
|
+
const info = await statRegularFile("/etc/app/config.json");
|
|
139
|
+
if (info.missing) {
|
|
147
140
|
await writeJson("/etc/app/config.json", defaultConfig);
|
|
148
|
-
} else if (r.regular) {
|
|
149
|
-
applyConfig(JSON.parse(r.buffer.toString("utf8")));
|
|
150
141
|
} else {
|
|
151
|
-
|
|
142
|
+
const r = await readRegularFile({
|
|
143
|
+
filePath: "/etc/app/config.json",
|
|
144
|
+
maxBytes: 64 * 1024,
|
|
145
|
+
});
|
|
146
|
+
applyConfig(JSON.parse(r.buffer.toString("utf8")));
|
|
152
147
|
}
|
|
153
148
|
```
|
|
154
149
|
|
|
@@ -156,7 +151,7 @@ if (r.missing) {
|
|
|
156
151
|
|
|
157
152
|
```ts
|
|
158
153
|
const r = await statRegularFile(p);
|
|
159
|
-
if (r.missing
|
|
154
|
+
if (r.missing) return false;
|
|
160
155
|
return true;
|
|
161
156
|
```
|
|
162
157
|
|
|
@@ -164,7 +159,6 @@ return true;
|
|
|
164
159
|
|
|
165
160
|
```ts
|
|
166
161
|
const r = await readRegularFile({ filePath: logPath, maxBytes: 1 * 1024 * 1024 });
|
|
167
|
-
if (r.missing || !r.regular) return [];
|
|
168
162
|
return r.buffer.toString("utf8").split("\n").slice(-100);
|
|
169
163
|
```
|
|
170
164
|
|
package/docs/security-model.md
CHANGED
|
@@ -90,6 +90,7 @@ The library does not advertise different security guarantees per platform — it
|
|
|
90
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. |
|
|
91
91
|
| Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
|
|
92
92
|
| Helper failures degrade fd-relative hardening | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode. Atomicity and identity checks remain, but parent-directory swaps between validation and mutation are less tightly pinned without the helper. |
|
|
93
|
+
| FUSE mounts with rename-unstable inode numbers | Some FUSE mounts (rclone is a confirmed example) do not preserve source inode identity at the rename destination. The explicit `renameIdentity: "verify-content-with-lock"` compatibility mode verifies content under a cooperative lock for that boundary only; subsequent path identity checks and the default remain strict. See [Writing](writing.md) for the weaker opt-in contract. |
|
|
93
94
|
|
|
94
95
|
## Recommended deployment shape
|
|
95
96
|
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -51,7 +51,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
51
51
|
staleMs?: number; // default 30_000
|
|
52
52
|
timeoutMs?: number; // overall acquire deadline; default unbounded
|
|
53
53
|
retry?: FileLockRetryOptions;
|
|
54
|
-
staleRecovery?: "fail-closed" | "remove-if-unchanged"; //
|
|
54
|
+
staleRecovery?: "fail-closed" | "remove-if-unchanged"; // legacy value also fails closed
|
|
55
55
|
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
|
|
56
56
|
payload: () => TPayload | Promise<TPayload>;
|
|
57
57
|
shouldReclaim?: (params: {
|
|
@@ -62,7 +62,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
62
62
|
nowMs: number;
|
|
63
63
|
heldByThisProcess: boolean;
|
|
64
64
|
}) => boolean | Promise<boolean>;
|
|
65
|
-
shouldRemoveStaleLock?: (snapshot: {
|
|
65
|
+
shouldRemoveStaleLock?: (snapshot: { // deprecated; retained but not invoked
|
|
66
66
|
lockPath: string;
|
|
67
67
|
normalizedTargetPath: string;
|
|
68
68
|
raw: string;
|
|
@@ -170,40 +170,24 @@ const handle = await acquireFileLock(targetPath, {
|
|
|
170
170
|
});
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale
|
|
173
|
+
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale and acquisition throws an error with code `file_lock_stale`.
|
|
174
174
|
|
|
175
|
-
## Stale recovery
|
|
175
|
+
## Stale recovery is fail-closed
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
fs-safe never removes a stale third-party sidecar during acquisition. Checking a pathname's content and identity before unlinking it is not atomic: another process can replace the lock between the final check and the unlink. Every stale result therefore fails closed with error code `file_lock_stale`.
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
const handle = await acquireFileLock(targetPath, {
|
|
181
|
-
staleMs: 60_000,
|
|
182
|
-
staleRecovery: "remove-if-unchanged",
|
|
183
|
-
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
|
|
184
|
-
shouldReclaim: ({ payload }) => {
|
|
185
|
-
const pid = Number(payload?.pid);
|
|
186
|
-
return Number.isInteger(pid) && pid > 0 && ownerIsDefinitelyDead(pid);
|
|
187
|
-
},
|
|
188
|
-
shouldRemoveStaleLock: ({ payload }) => {
|
|
189
|
-
const pid = Number(payload?.pid);
|
|
190
|
-
return Number.isInteger(pid) && pid > 0 && ownerIsDefinitelyDead(pid);
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
`shouldRemoveStaleLock` receives the exact lock snapshot that `fs-safe` inspected. `fs-safe` re-reads the sidecar and removes it only if the raw content and file identity are unchanged. If the callback is missing, returns false, or the file changed, acquisition fails closed or keeps retrying according to the normal retry policy.
|
|
179
|
+
The `"remove-if-unchanged"` value and `shouldRemoveStaleLock` callback remain accepted as deprecated compatibility inputs. They are ignored and the callback is not invoked. To recover, stop or otherwise exclude every process that can acquire the lock, remove the confirmed stale sidecar under that external authority, and retry acquisition.
|
|
196
180
|
|
|
197
181
|
## What sidecar locks defend against
|
|
198
182
|
|
|
199
183
|
- **Two processes writing the same file at once.** `acquire` serializes the critical section.
|
|
200
|
-
- **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks fail closed
|
|
184
|
+
- **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks always fail closed and are never unlinked during acquisition.
|
|
201
185
|
- **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
|
|
202
186
|
|
|
203
187
|
## What they do **not** defend against
|
|
204
188
|
|
|
205
189
|
- **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
|
|
206
|
-
- **Automatic stale lock deletion.** If a process crashes,
|
|
190
|
+
- **Automatic stale lock deletion.** If a process crashes, recover only under external authority that excludes every competing lock acquirer.
|
|
207
191
|
- **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
|
|
208
192
|
|
|
209
193
|
## Common patterns
|
package/docs/writing.md
CHANGED
|
@@ -192,7 +192,7 @@ await fs.write("data/blob.bin", buffer, { mkdir: false }); // override
|
|
|
192
192
|
| `not-found` | Parent does not exist and `mkdir` is false. |
|
|
193
193
|
| `not-empty` | `remove()` on a non-empty directory. |
|
|
194
194
|
| `not-removable` | `remove()` could not unlink/rmdir (typically permissions or device busy). |
|
|
195
|
-
| `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer. |
|
|
195
|
+
| `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer, or a FUSE mount with unstable inode numbers — see `renameIdentity` below. |
|
|
196
196
|
| `too-large` | `copyIn()` source exceeded `maxBytes`. |
|
|
197
197
|
| `symlink` | A path component is a symlink and policy is `reject`. |
|
|
198
198
|
| `hardlink` | `sourceHardlinks: "reject"` saw `nlink > 1`. |
|
|
@@ -232,6 +232,26 @@ try {
|
|
|
232
232
|
await fs.append(today, line);
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
+
## FUSE mounts and unstable inode numbers
|
|
236
|
+
|
|
237
|
+
Some FUSE mounts — rclone is a confirmed example — assign the destination a different inode number from the source temp file as a result of rename, even within a single process with zero concurrency. Repeated stats of an unchanged destination remain stable, but the source-to-destination `(dev, ino)` comparison always fails with `path-mismatch`.
|
|
238
|
+
|
|
239
|
+
Set `renameIdentity: "verify-content-with-lock"` on the root (or per call) to use a SHA-256 content comparison under a cooperative sidecar lock instead:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const fs = await root("/mnt/rclone-workspace", {
|
|
243
|
+
renameIdentity: "verify-content-with-lock",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await fs.write("state.json", body); // succeeds on rclone FUSE
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**How it works.** The full write runs under an exclusive per-target lock named `.fs-safe-write-<sha256>.lock` in the root. Keeping the lock in the already-canonical root avoids creating an unguarded lock path through a missing or raced target parent. The guarded Node fallback accepts the source-temp-to-destination inode mismatch only when the SHA-256 of the re-read bytes matches the SHA-256 of the bytes written. Subsequent path identity checks remain strict, so this mode requires an unchanged destination path to report stable identity. It deliberately bypasses the stricter fd-relative Python helper because that helper requires rename to preserve inode identity. The lock is released before the call returns.
|
|
250
|
+
|
|
251
|
+
**Security note.** `verify-content-with-lock` proves that the bytes observed after rename match the requested write and prevents *cooperating* writers from interleaving. It does **not** prove that the destination still names the temp-file object, retain the Python helper's fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.
|
|
252
|
+
|
|
253
|
+
Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-is-fail-closed).
|
|
254
|
+
|
|
235
255
|
## See also
|
|
236
256
|
|
|
237
257
|
- [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` and friends.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -97,24 +97,12 @@
|
|
|
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
|
-
},
|
|
113
100
|
"scripts": {
|
|
114
101
|
"benchmark": "node scripts/benchmark.mjs",
|
|
115
102
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
116
103
|
"lint:file-size": "node scripts/check-file-size.mjs",
|
|
117
104
|
"lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
|
|
105
|
+
"prepack": "node scripts/prepack-build.mjs",
|
|
118
106
|
"test": "vitest run",
|
|
119
107
|
"test:coverage": "vitest run --coverage",
|
|
120
108
|
"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",
|
|
@@ -126,5 +114,20 @@
|
|
|
126
114
|
"crabbox:run": "crabbox run",
|
|
127
115
|
"crabbox:stop": "crabbox stop",
|
|
128
116
|
"crabbox:warmup": "crabbox warmup"
|
|
129
|
-
}
|
|
130
|
-
|
|
117
|
+
},
|
|
118
|
+
"optionalDependencies": {
|
|
119
|
+
"jszip": "^3.10.1",
|
|
120
|
+
"tar": "7.5.20"
|
|
121
|
+
},
|
|
122
|
+
"devDependencies": {
|
|
123
|
+
"@types/node": "^26.1.1",
|
|
124
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
125
|
+
"typescript": "^7.0.2",
|
|
126
|
+
"vite": "8.1.5",
|
|
127
|
+
"vitest": "^4.1.10"
|
|
128
|
+
},
|
|
129
|
+
"engines": {
|
|
130
|
+
"node": ">=22"
|
|
131
|
+
},
|
|
132
|
+
"packageManager": "pnpm@10.34.5"
|
|
133
|
+
}
|