@openclaw/fs-safe 0.4.1 → 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 (42) hide show
  1. package/CHANGELOG.md +15 -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-store.d.ts.map +1 -1
  14. package/dist/file-store.js +5 -2
  15. package/dist/json.d.ts +8 -5
  16. package/dist/json.d.ts.map +1 -1
  17. package/dist/json.js +27 -14
  18. package/dist/pinned-python.js +10 -10
  19. package/dist/pinned-write.d.ts.map +1 -1
  20. package/dist/pinned-write.js +12 -2
  21. package/dist/read-opened-file.d.ts.map +1 -1
  22. package/dist/read-opened-file.js +4 -4
  23. package/dist/regular-file.d.ts.map +1 -1
  24. package/dist/regular-file.js +11 -52
  25. package/dist/root-context.d.ts.map +1 -1
  26. package/dist/root-path.d.ts.map +1 -1
  27. package/dist/secret-file.d.ts.map +1 -1
  28. package/dist/secret-file.js +5 -2
  29. package/dist/secure-file.d.ts.map +1 -1
  30. package/dist/secure-file.js +6 -7
  31. package/dist/sidecar-lock.d.ts +5 -1
  32. package/dist/sidecar-lock.d.ts.map +1 -1
  33. package/dist/sidecar-lock.js +3 -42
  34. package/docs/advanced.md +20 -0
  35. package/docs/config.md +2 -2
  36. package/docs/index.md +1 -1
  37. package/docs/json-store.md +2 -2
  38. package/docs/json.md +13 -5
  39. package/docs/regular-file.md +26 -32
  40. package/docs/sidecar-lock.md +8 -24
  41. package/docs/writing.md +1 -1
  42. package/package.json +19 -17
@@ -1,6 +1,7 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { readFileDescriptorBoundedSync, readFileHandleBounded } from "./bounded-read.js";
4
5
  import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
5
6
  import { FsSafeError } from "./errors.js";
6
7
  import { sameFileIdentity } from "./file-identity.js";
@@ -19,44 +20,6 @@ function resolveRegularFileReadFlags() {
19
20
  ? fsSync.constants.O_NOFOLLOW
20
21
  : 0));
21
22
  }
22
- async function readFileHandleBounded(params) {
23
- if (params.maxBytes === undefined) {
24
- return await params.handle.readFile();
25
- }
26
- const chunks = [];
27
- const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
28
- let total = 0;
29
- while (true) {
30
- const { bytesRead } = await params.handle.read(scratch, 0, scratch.length, null);
31
- if (bytesRead === 0) {
32
- return Buffer.concat(chunks, total);
33
- }
34
- total += bytesRead;
35
- if (total > params.maxBytes) {
36
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
37
- }
38
- chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
39
- }
40
- }
41
- function readFileDescriptorBounded(params) {
42
- if (params.maxBytes === undefined) {
43
- return fsSync.readFileSync(params.fd);
44
- }
45
- const chunks = [];
46
- const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
47
- let total = 0;
48
- while (true) {
49
- const bytesRead = fsSync.readSync(params.fd, scratch, 0, scratch.length, null);
50
- if (bytesRead === 0) {
51
- return Buffer.concat(chunks, total);
52
- }
53
- total += bytesRead;
54
- if (total > params.maxBytes) {
55
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
56
- }
57
- chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
58
- }
59
- }
60
23
  export async function statRegularFile(filePath) {
61
24
  let stat;
62
25
  try {
@@ -96,7 +59,7 @@ export async function readRegularFile(params) {
96
59
  throw Object.assign(new Error(`File not found: ${params.filePath}`), { code: "ENOENT" });
97
60
  }
98
61
  if (params.maxBytes !== undefined && result.stat.size > params.maxBytes) {
99
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
62
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${result.stat.size})`);
100
63
  }
101
64
  let handle;
102
65
  try {
@@ -127,15 +90,13 @@ export async function readRegularFile(params) {
127
90
  preOpenStat: result.stat,
128
91
  });
129
92
  if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
130
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
93
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${stat.size})`);
131
94
  }
132
95
  // With a byte cap, avoid readFile(): a raced file growth would allocate
133
96
  // the oversized content before the post-read check could reject it.
134
- const buffer = await readFileHandleBounded({
135
- handle,
136
- filePath: params.filePath,
137
- maxBytes: params.maxBytes,
138
- });
97
+ const buffer = params.maxBytes === undefined
98
+ ? await handle.readFile()
99
+ : await readFileHandleBounded(handle, params.maxBytes);
139
100
  return { buffer, stat };
140
101
  }
141
102
  finally {
@@ -160,15 +121,13 @@ function readOpenedRegularFileSync(params) {
160
121
  preOpenStat: params.preOpenStat,
161
122
  });
162
123
  if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
163
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
124
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${stat.size})`);
164
125
  }
165
126
  // Keep capped sync reads incremental for the same reason as async reads:
166
127
  // readFileSync(fd) would buffer a raced oversized file before throwing.
167
- const buffer = readFileDescriptorBounded({
168
- fd: params.fd,
169
- filePath: params.filePath,
170
- maxBytes: params.maxBytes,
171
- });
128
+ const buffer = params.maxBytes === undefined
129
+ ? fsSync.readFileSync(params.fd)
130
+ : readFileDescriptorBoundedSync(params.fd, params.maxBytes);
172
131
  return { buffer, stat };
173
132
  }
174
133
  export function readRegularFileSync(params) {
@@ -178,7 +137,7 @@ export function readRegularFileSync(params) {
178
137
  throw Object.assign(new Error(`File not found: ${params.filePath}`), { code: "ENOENT" });
179
138
  }
180
139
  if (params.maxBytes !== undefined && result.stat.size > params.maxBytes) {
181
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
140
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${result.stat.size})`);
182
141
  }
183
142
  const fd = fsSync.openSync(params.filePath, resolveRegularFileReadFlags());
184
143
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"root-context.d.ts","sourceRoot":"","sources":["../src/root-context.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,WACM,CAAC;AAEtD,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEtE;AAID,wBAAsB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYtF;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAuB9E;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,WAAW,EACjB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAQtE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAEvE"}
1
+ {"version":3,"file":"root-context.d.ts","sourceRoot":"","sources":["../src/root-context.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAW,MAAM,WACM,CAAC;AAEtD,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEtE;AAID,wBAAsB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYtF;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAuB9E;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,WAAW,EACjB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAQtE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAEvE"}
@@ -1 +1 @@
1
- {"version":3,"file":"root-path.d.ts","sourceRoot":"","sources":["../src/root-path.ts"],"names":[],"mappings":"AAMA,KAAK,cAAc,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEtE,MAAM,MAAM,mBAAmB,GAAG;IAChC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC,CAAC;AAEF,eAAO,MAAM,wBAAwB;;;;;;;;;CAS3B,CAAC;AAEX,KAAK,qBAAqB,GAAG;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,KAAK,oBAAoB,GAAG,SAAS,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AAEnF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,oBAAoB,CAAC;CAC5B,CAAC;AAEF,wBAAsB,eAAe,CACnC,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,gBAAgB,CAAC,CA+B3B;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,qBAAqB,GAAG,gBAAgB,CA+BnF;AAmkBD,wBAAgB,kCAAkC,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CA6B7E"}
1
+ {"version":3,"file":"root-path.d.ts","sourceRoot":"","sources":["../src/root-path.ts"],"names":[],"mappings":"AAMA,KAAK,cAAc,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEtE,MAAM,MAAM,mBAAmB,GAAG;IAChC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC,CAAC;AAEF,eAAO,MAAM,wBAAwB;aACnC,MAAM;;;;aAIN,YAAY;;;;CAIJ,CAAC;AAEX,KAAK,qBAAqB,GAAG;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,KAAK,oBAAoB,GAAG,SAAS,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AAEnF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,oBAAoB,CAAC;CAC5B,CAAC;AAEF,wBAAsB,eAAe,CACnC,MAAM,EAAE,qBAAqB,GAC5B,OAAO,CAAC,gBAAgB,CAAC,CA+B3B;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,qBAAqB,GAAG,gBAAgB,CA+BnF;AAmkBD,wBAAgB,kCAAkC,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CA6B7E"}
@@ -1 +1 @@
1
- {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAkIF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAcpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
1
+ {"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAqIF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAcpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { readFileDescriptorBoundedSync } from "./bounded-read.js";
4
5
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
5
6
  import { FsSafeError } from "./errors.js";
6
7
  import { sameFileIdentity } from "./file-identity.js";
@@ -100,7 +101,7 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
100
101
  };
101
102
  }
102
103
  try {
103
- const raw = fs.readFileSync(opened.fd, "utf8");
104
+ const raw = readFileDescriptorBoundedSync(opened.fd, maxBytes).toString("utf8");
104
105
  const secret = raw.trim();
105
106
  if (!secret) {
106
107
  return {
@@ -115,7 +116,9 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
115
116
  const normalized = normalizeSecretReadError(error);
116
117
  return {
117
118
  ok: false,
118
- code: "invalid-path",
119
+ code: error instanceof FsSafeError && error.code === "too-large"
120
+ ? "too-large"
121
+ : "invalid-path",
119
122
  error: normalized,
120
123
  message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`,
121
124
  };
@@ -1 +1 @@
1
- {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAuKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
1
+ {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AA0KF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
@@ -1,6 +1,7 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { readFileHandleBounded } from "./bounded-read.js";
4
5
  import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
5
6
  import { FsSafeError } from "./errors.js";
6
7
  import { sameFileIdentity } from "./file-identity.js";
@@ -129,14 +130,15 @@ async function assertSecurePermissions(options, stat, realPath) {
129
130
  }
130
131
  return permissions;
131
132
  }
132
- async function readHandleWithTimeout(handle, timeoutMs) {
133
+ async function readHandleWithTimeout(handle, timeoutMs, maxBytes) {
134
+ const read = () => maxBytes === undefined ? handle.readFile() : readFileHandleBounded(handle, maxBytes);
133
135
  if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
134
- return await handle.readFile();
136
+ return await read();
135
137
  }
136
138
  let timeout;
137
139
  try {
138
140
  return await Promise.race([
139
- handle.readFile(),
141
+ read(),
140
142
  new Promise((_resolve, reject) => {
141
143
  timeout = setTimeout(() => {
142
144
  void handle.close().catch(() => undefined);
@@ -155,10 +157,7 @@ export async function readSecureFile(options) {
155
157
  try {
156
158
  await assertTrustedDirs(options, opened.realPath);
157
159
  const permissions = await assertSecurePermissions(options, opened.pathStat, opened.realPath);
158
- const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs);
159
- if (options.io?.maxBytes !== undefined && buffer.byteLength > options.io.maxBytes) {
160
- throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
161
- }
160
+ const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs, options.io?.maxBytes);
162
161
  return { buffer, realPath: opened.realPath, stat: opened.pathStat, permissions };
163
162
  }
164
163
  finally {
@@ -5,7 +5,10 @@ export type SidecarLockRetryOptions = {
5
5
  maxTimeout?: number;
6
6
  randomize?: boolean;
7
7
  };
8
- export type SidecarLockStaleRecovery = "fail-closed" | "remove-if-unchanged";
8
+ export type SidecarLockStaleRecovery = "fail-closed"
9
+ /** @deprecated Stale locks now always fail closed. */
10
+ | "remove-if-unchanged";
11
+ /** @deprecated Stale-removal callbacks are retained for source compatibility but are not invoked. */
9
12
  export type SidecarLockStaleSnapshot = {
10
13
  lockPath: string;
11
14
  normalizedTargetPath: string;
@@ -29,6 +32,7 @@ export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>>
29
32
  nowMs: number;
30
33
  heldByThisProcess: boolean;
31
34
  }) => boolean | Promise<boolean>;
35
+ /** @deprecated Stale locks now always fail closed; this callback is not invoked. */
32
36
  shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
33
37
  metadata?: Record<string, unknown>;
34
38
  };
@@ -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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"}
@@ -67,36 +67,6 @@ async function lockSnapshotStillPresent(lockPath, observed) {
67
67
  const current = await readLockSnapshot(lockPath);
68
68
  return !!current && !!observed && snapshotMatches(current, observed);
69
69
  }
70
- async function removeStaleLockIfAllowed(params) {
71
- if (!params.shouldRemoveStaleLock) {
72
- return "not-approved";
73
- }
74
- if (params.snapshot.raw === undefined) {
75
- return "not-approved";
76
- }
77
- if (!(await params.shouldRemoveStaleLock({
78
- lockPath: params.lockPath,
79
- normalizedTargetPath: params.normalizedTargetPath,
80
- raw: params.snapshot.raw,
81
- payload: params.snapshot.payload,
82
- }))) {
83
- return "not-approved";
84
- }
85
- const current = await readLockSnapshot(params.lockPath);
86
- if (!current || !snapshotMatches(current, params.snapshot)) {
87
- return "changed";
88
- }
89
- try {
90
- await fs.rm(params.lockPath, { force: true });
91
- }
92
- catch (err) {
93
- if (err.code === "ENOENT") {
94
- return "changed";
95
- }
96
- return "not-approved";
97
- }
98
- return "removed";
99
- }
100
70
  function snapshotMatchesSync(lockPath, observed) {
101
71
  try {
102
72
  const stat = fsSync.lstatSync(lockPath);
@@ -281,18 +251,9 @@ export function createSidecarLockManager(key) {
281
251
  if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
282
252
  continue;
283
253
  }
284
- const staleRecovery = options.staleRecovery ?? "fail-closed";
285
- if (staleRecovery === "remove-if-unchanged") {
286
- const removal = await removeStaleLockIfAllowed({
287
- lockPath,
288
- normalizedTargetPath,
289
- snapshot,
290
- shouldRemoveStaleLock: options.shouldRemoveStaleLock,
291
- });
292
- if (removal === "removed" || removal === "changed") {
293
- continue;
294
- }
295
- }
254
+ // A pathname recheck followed by unlink is not atomic: a fresh lock
255
+ // can replace the observed file in between. Legacy recovery inputs
256
+ // remain accepted, but third-party stale locks always fail closed.
296
257
  throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
297
258
  code: "file_lock_stale",
298
259
  lockPath,
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