@openclaw/fs-safe 0.4.4 → 0.4.5

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.5 - 2026-07-20
4
+
5
+ ### Highlights
6
+
7
+ - Preserve sidecar-lock ownership across filesystem identity drift without weakening legacy stale-lock checks.
8
+
9
+ ### Security and Correctness
10
+
11
+ - Give new sidecar locks an internal random ownership token outside the parsed JSON payload so release remains ownership-checked when virtual filesystems report different descriptor and pathname identities, while legacy locks retain the stricter identity-plus-content check.
12
+
3
13
  ## 0.4.4 - 2026-07-18
4
14
 
5
15
  ### Security and Correctness
@@ -9,6 +9,12 @@ export type SidecarLockSnapshot = {
9
9
  raw?: string;
10
10
  payload: Record<string, unknown> | null;
11
11
  stat?: Stats;
12
+ ownershipToken?: string;
13
+ };
14
+ export declare function readSidecarLockOwnershipToken(raw: string): string | undefined;
15
+ export declare function serializeSidecarLockPayload(payload: Record<string, unknown>): {
16
+ raw: string;
17
+ ownershipToken: string;
12
18
  };
13
19
  export declare function readSidecarLockSnapshot(lockPath: string): Promise<SidecarLockSnapshot | null>;
14
20
  export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAIrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,CAAC;AAEF,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAQT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
1
+ {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAYF,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7E;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;CACxB,CAMA;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAkBT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
@@ -1,5 +1,29 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import { sameFileIdentity } from "./file-identity.js";
4
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES = 16;
5
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS = SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES * 8;
6
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX = "\t".repeat(8);
7
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN = new RegExp(`\\n(${SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX}[ \\t]{${SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS}})\\n$`);
8
+ function createSidecarLockOwnershipToken() {
9
+ let token = SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX;
10
+ for (const byte of randomBytes(SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES)) {
11
+ for (let bit = 7; bit >= 0; bit -= 1) {
12
+ token += byte & (1 << bit) ? "\t" : " ";
13
+ }
14
+ }
15
+ return token;
16
+ }
17
+ export function readSidecarLockOwnershipToken(raw) {
18
+ return SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN.exec(raw)?.[1];
19
+ }
20
+ export function serializeSidecarLockPayload(payload) {
21
+ const ownershipToken = createSidecarLockOwnershipToken();
22
+ return {
23
+ raw: `${JSON.stringify(payload, null, 2)}\n${ownershipToken}\n`,
24
+ ownershipToken,
25
+ };
26
+ }
3
27
  export async function readSidecarLockSnapshot(lockPath) {
4
28
  try {
5
29
  const stat = await fs.lstat(lockPath);
@@ -23,6 +47,14 @@ export async function readSidecarLockSnapshot(lockPath) {
23
47
  }
24
48
  }
25
49
  export function sidecarLockSnapshotMatches(current, observed) {
50
+ if (observed.ownershipToken !== undefined) {
51
+ return (current.stat?.isFile() === true &&
52
+ current.raw !== undefined &&
53
+ observed.raw !== undefined &&
54
+ readSidecarLockOwnershipToken(current.raw) === observed.ownershipToken &&
55
+ readSidecarLockOwnershipToken(observed.raw) === observed.ownershipToken &&
56
+ current.raw === observed.raw);
57
+ }
26
58
  if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
27
59
  return false;
28
60
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@ import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { sameFileIdentity } from "./file-identity.js";
5
- import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
5
+ import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotMatches, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
6
6
  const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
7
7
  function getGlobalManagers() {
8
8
  const globalWithState = globalThis;
@@ -32,16 +32,45 @@ function resolveManagerState(key) {
32
32
  return state;
33
33
  }
34
34
  function snapshotMatchesSync(lockPath, observed) {
35
+ let fd;
35
36
  try {
36
- const stat = fsSync.lstatSync(lockPath);
37
- if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
37
+ const beforeStat = fsSync.lstatSync(lockPath);
38
+ if (!beforeStat.isFile()) {
38
39
  return false;
39
40
  }
40
- return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
41
+ const openFlags = fsSync.constants.O_RDONLY |
42
+ (process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
43
+ ? fsSync.constants.O_NOFOLLOW
44
+ : 0) |
45
+ (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
46
+ fd = fsSync.openSync(lockPath, openFlags);
47
+ const openedStat = fsSync.fstatSync(fd);
48
+ if (!openedStat.isFile()) {
49
+ return false;
50
+ }
51
+ if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
52
+ return false;
53
+ }
54
+ const raw = fsSync.readFileSync(fd, "utf8");
55
+ const afterStat = fsSync.lstatSync(lockPath);
56
+ if (!afterStat.isFile() || !sameFileIdentity(beforeStat, afterStat)) {
57
+ return false;
58
+ }
59
+ return sidecarLockSnapshotMatches({ raw, payload: null, stat: afterStat }, observed);
41
60
  }
42
61
  catch {
43
62
  return false;
44
63
  }
64
+ finally {
65
+ if (fd !== undefined) {
66
+ try {
67
+ fsSync.closeSync(fd);
68
+ }
69
+ catch {
70
+ // Best-effort process-exit cleanup.
71
+ }
72
+ }
73
+ }
45
74
  }
46
75
  async function resolveNormalizedTargetPath(targetPath) {
47
76
  const resolved = path.resolve(targetPath);
@@ -197,9 +226,9 @@ export function createSidecarLockManager(key) {
197
226
  try {
198
227
  handle = await fs.open(lockPath, "wx");
199
228
  const payload = await options.payload();
200
- const raw = `${JSON.stringify(payload, null, 2)}\n`;
229
+ const { raw, ownershipToken } = serializeSidecarLockPayload(payload);
201
230
  await handle.writeFile(raw, "utf8");
202
- const snapshot = { raw, payload, stat: await handle.stat() };
231
+ const snapshot = { raw, payload, stat: await handle.stat(), ownershipToken };
203
232
  const createdHeld = {
204
233
  count: 1,
205
234
  handle,
@@ -23,6 +23,10 @@ The lock file sits next to the protected resource. If a process crashes mid-lock
23
23
 
24
24
  The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; recover only after an application-owned liveness policy proves the holder cannot still be writing.
25
25
 
26
+ Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
27
+
28
+ The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
29
+
26
30
  ## API
27
31
 
28
32
  ```ts
package/docs/writing.md CHANGED
@@ -250,7 +250,7 @@ await fs.write("state.json", body); // succeeds on rclone FUSE
250
250
 
251
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
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).
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-guarded-remove-if-unchanged).
254
254
 
255
255
  ## See also
256
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {