@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +4 -2
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-limits.d.ts.map +1 -1
  7. package/dist/archive-staging.d.ts.map +1 -1
  8. package/dist/async-lock.d.ts.map +1 -1
  9. package/dist/bounded-read.d.ts +14 -0
  10. package/dist/bounded-read.d.ts.map +1 -0
  11. package/dist/bounded-read.js +86 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/file-identity.d.ts +1 -0
  14. package/dist/file-identity.d.ts.map +1 -1
  15. package/dist/file-identity.js +10 -2
  16. package/dist/file-store.d.ts.map +1 -1
  17. package/dist/file-store.js +5 -2
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/json.d.ts +8 -5
  21. package/dist/json.d.ts.map +1 -1
  22. package/dist/json.js +27 -14
  23. package/dist/pinned-python.js +10 -10
  24. package/dist/pinned-write.d.ts +9 -1
  25. package/dist/pinned-write.d.ts.map +1 -1
  26. package/dist/pinned-write.js +88 -5
  27. package/dist/read-opened-file.d.ts.map +1 -1
  28. package/dist/read-opened-file.js +4 -4
  29. package/dist/regular-file.d.ts.map +1 -1
  30. package/dist/regular-file.js +11 -52
  31. package/dist/root-context.d.ts.map +1 -1
  32. package/dist/root-impl.d.ts +4 -1
  33. package/dist/root-impl.d.ts.map +1 -1
  34. package/dist/root-impl.js +10 -7
  35. package/dist/root-path.d.ts.map +1 -1
  36. package/dist/root.d.ts +1 -1
  37. package/dist/root.d.ts.map +1 -1
  38. package/dist/secret-file.d.ts.map +1 -1
  39. package/dist/secret-file.js +5 -2
  40. package/dist/secure-file.d.ts.map +1 -1
  41. package/dist/secure-file.js +6 -7
  42. package/dist/sidecar-lock.d.ts +5 -1
  43. package/dist/sidecar-lock.d.ts.map +1 -1
  44. package/dist/sidecar-lock.js +3 -42
  45. package/dist/test-hooks.d.ts +1 -0
  46. package/dist/test-hooks.d.ts.map +1 -1
  47. package/docs/advanced.md +20 -0
  48. package/docs/config.md +2 -2
  49. package/docs/index.md +1 -1
  50. package/docs/json-store.md +2 -2
  51. package/docs/json.md +13 -5
  52. package/docs/regular-file.md +26 -32
  53. package/docs/security-model.md +1 -0
  54. package/docs/sidecar-lock.md +8 -24
  55. package/docs/writing.md +21 -1
  56. 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"` is available for callers that also pass `shouldRemoveStaleLock`; fs-safe re-reads the observed sidecar and removes it only when the raw content and file identity still match.
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, fail-closed stale-lock handling, and opt-in caller-approved stale removal. |
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, …). |
@@ -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 do not expose `shouldRemoveStaleLock`, so `staleRecovery: "remove-if-unchanged"` cannot remove a stale sidecar by itself. Use the lower-level [file lock](sidecar-lock.md) API when your application needs custom owner-liveness checks and caller-approved stale-lock removal.
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
- ### `readJsonIfExists<T>(filePath)`
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) {
@@ -32,14 +32,14 @@ type RegularFileStatResult =
32
32
  | { missing: false; stat: Stats };
33
33
  ```
34
34
 
35
- A non-regular file (directory, FIFO, …) returns `{ missing: false }` with a `stat` whose `isFile()` is false — the helper does not throw, you decide what to do.
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
- Result shape:
69
-
70
- ```ts
71
- type Result =
72
- | { missing: true }
73
- | { missing: false; regular: false; stat: Stats }
74
- | { missing: false; regular: true; stat: Stats; buffer: Buffer };
75
- ```
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
- data: `[${new Date().toISOString()}] ${line}\n`,
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
- data: string | Buffer;
106
- encoding?: BufferEncoding; // default utf8 when data is string
107
- prependNewlineIfNeeded?: boolean; // insert "\n" if file does not end with one
108
- flags?: number; // raw open flags; default O_WRONLY | O_APPEND
109
- mode?: number; // default 0o644 if file is created
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
- `prependNewlineIfNeeded` reads the trailing byte of the existing file and prepends a `\n` to your data if it isn't already present. Useful for log appenders that want to preserve line boundaries even when callers forget the newline.
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(append, truncateExisting)`
111
+ ### `resolveRegularFileAppendFlags()`
120
112
 
121
- Helper that returns the right open-flag bitmask for combinations of "append" / "truncate". Use it when you're building your own open path and want to match the append helpers' behavior:
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(true, false); // O_WRONLY | O_APPEND | O_CREAT
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
- | No identity check post-open. | Identity check on every read/write. |
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
- | Returns explicit `{missing, regular}` shape. | Throws `FsSafeError` with `code`. |
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 r = await readRegularFile({ filePath: "/etc/app/config.json", maxBytes: 64 * 1024 });
146
- if (r.missing) {
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
- throw new Error("/etc/app/config.json is not a regular file");
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 || !r.stat.isFile()) return false;
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
 
@@ -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
 
@@ -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"; // default "fail-closed"
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; `staleRecovery` then decides whether acquisition fails closed or tries caller-approved removal.
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: `remove-if-unchanged`
175
+ ## Stale recovery is fail-closed
176
176
 
177
- The default `staleRecovery: "fail-closed"` never removes third-party sidecars. Use `staleRecovery: "remove-if-unchanged"` only when your app has a reliable owner-liveness policy and can prove a stale owner cannot still be writing.
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
- ```ts
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 by default. Opt-in removal rechecks the observed snapshot before unlinking.
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, use the payload and your own supervisor/process table to decide when removal is safe, then opt into `remove-if-unchanged`.
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.0",
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
+ }