@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.
- package/CHANGELOG.md +15 -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-store.d.ts.map +1 -1
- package/dist/file-store.js +5 -2
- 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.map +1 -1
- package/dist/pinned-write.js +12 -2
- 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-path.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/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/sidecar-lock.md +8 -24
- package/docs/writing.md +1 -1
- package/package.json +19 -17
package/dist/regular-file.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
135
|
-
handle
|
|
136
|
-
|
|
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
|
|
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 =
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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,
|
|
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"}
|
package/dist/root-path.d.ts.map
CHANGED
|
@@ -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
|
|
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":"
|
|
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"}
|
package/dist/secret-file.js
CHANGED
|
@@ -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 =
|
|
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: "
|
|
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;
|
|
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"}
|
package/dist/secure-file.js
CHANGED
|
@@ -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
|
|
136
|
+
return await read();
|
|
135
137
|
}
|
|
136
138
|
let timeout;
|
|
137
139
|
try {
|
|
138
140
|
return await Promise.race([
|
|
139
|
-
|
|
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 {
|
package/dist/sidecar-lock.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ export type SidecarLockRetryOptions = {
|
|
|
5
5
|
maxTimeout?: number;
|
|
6
6
|
randomize?: boolean;
|
|
7
7
|
};
|
|
8
|
-
export type SidecarLockStaleRecovery = "fail-closed"
|
|
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":"AAOA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,
|
|
1
|
+
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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"`
|
|
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
|
|