@openclaw/fs-safe 0.2.4 → 0.2.6
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 +21 -1
- package/README.md +8 -9
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +12 -1
- package/dist/file-store-boundary.js +2 -2
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +6 -5
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +6 -6
- package/dist/json-document-store.d.ts +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +5 -3
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +157 -4
- package/dist/json-stringify.d.ts +2 -0
- package/dist/json-stringify.d.ts.map +1 -0
- package/dist/json-stringify.js +7 -0
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +3 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +4 -1
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +7 -10
- package/dist/pinned-python.d.ts.map +1 -1
- package/dist/pinned-python.js +158 -69
- package/dist/pinned-write.d.ts +3 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +54 -45
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +6 -2
- package/dist/root-path.js +2 -2
- package/dist/root-paths.js +25 -3
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +76 -50
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +3 -1
- package/dist/temp-target.js +1 -1
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +8 -1
- package/docs/index.md +1 -1
- package/docs/json-store.md +3 -1
- package/docs/python-helper.md +3 -3
- package/docs/root.md +5 -6
- package/package.json +4 -4
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/path-scope.d.ts +0 -2
- package/dist/path-scope.d.ts.map +0 -1
- package/dist/path-scope.js +0 -1
- package/dist/private-file-store.d.ts +0 -60
- package/dist/private-file-store.d.ts.map +0 -1
- package/dist/private-file-store.js +0 -233
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/safe-root.js +0 -1060
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- package/dist/sibling-temp-write.js +0 -40
package/dist/pinned-write.js
CHANGED
|
@@ -2,11 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fsSync from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import { pipeline } from "node:stream/promises";
|
|
7
|
-
import { createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
5
|
+
import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
8
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
9
8
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
9
|
+
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
10
10
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
11
11
|
import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
|
|
12
12
|
function byteLength(input, encoding) {
|
|
@@ -28,29 +28,21 @@ function assertWithinMaxBytes(bytes, maxBytes) {
|
|
|
28
28
|
throw new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
function
|
|
32
|
-
if (maxBytes === undefined) {
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
31
|
+
async function writeStreamToHandle(stream, handle, maxBytes) {
|
|
35
32
|
let bytes = 0;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
for await (const chunk of stream) {
|
|
34
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
35
|
+
bytes += buffer.byteLength;
|
|
36
|
+
assertWithinMaxBytes(bytes, maxBytes);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
while (offset < buffer.byteLength) {
|
|
39
|
+
const { bytesWritten } = await handle.write(buffer, offset, buffer.byteLength - offset);
|
|
40
|
+
if (bytesWritten <= 0) {
|
|
41
|
+
throw new FsSafeError("helper-failed", "fallback stream write made no progress");
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
async function pipelineWithMaxBytes(stream, destination, maxBytes) {
|
|
48
|
-
const limiter = createMaxBytesTransform(maxBytes);
|
|
49
|
-
if (limiter) {
|
|
50
|
-
await pipeline(stream, limiter, destination);
|
|
51
|
-
return;
|
|
43
|
+
offset += bytesWritten;
|
|
44
|
+
}
|
|
52
45
|
}
|
|
53
|
-
await pipeline(stream, destination);
|
|
54
46
|
}
|
|
55
47
|
async function inputToBase64(input, maxBytes) {
|
|
56
48
|
if (input.kind === "buffer") {
|
|
@@ -75,7 +67,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
75
67
|
relativeParentPath: params.relativeParentPath,
|
|
76
68
|
});
|
|
77
69
|
if (getFsSafePythonConfig().mode === "off") {
|
|
78
|
-
return await
|
|
70
|
+
return await runPinnedWriteFallbackOrThrow(params);
|
|
79
71
|
}
|
|
80
72
|
if (params.input.kind === "stream") {
|
|
81
73
|
try {
|
|
@@ -83,7 +75,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
83
75
|
}
|
|
84
76
|
catch (error) {
|
|
85
77
|
if (canFallbackFromPythonError(error)) {
|
|
86
|
-
return await
|
|
78
|
+
return await runPinnedWriteFallbackOrThrow(params, error);
|
|
87
79
|
}
|
|
88
80
|
throw error;
|
|
89
81
|
}
|
|
@@ -96,6 +88,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
96
88
|
mode: params.mode || 0o600,
|
|
97
89
|
overwrite: params.overwrite !== false,
|
|
98
90
|
relativeParentPath: params.relativeParentPath,
|
|
91
|
+
...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
|
|
99
92
|
};
|
|
100
93
|
try {
|
|
101
94
|
return await runPinnedPythonOperation({
|
|
@@ -106,7 +99,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
106
99
|
}
|
|
107
100
|
catch (error) {
|
|
108
101
|
if (canFallbackFromPythonError(error)) {
|
|
109
|
-
return await
|
|
102
|
+
return await runPinnedWriteFallbackOrThrow(params, error);
|
|
110
103
|
}
|
|
111
104
|
throw error;
|
|
112
105
|
}
|
|
@@ -126,22 +119,29 @@ export async function runPinnedCopyHelper(params) {
|
|
|
126
119
|
mode: params.mode || 0o600,
|
|
127
120
|
overwrite: params.overwrite !== false,
|
|
128
121
|
relativeParentPath: params.relativeParentPath,
|
|
122
|
+
...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
|
|
129
123
|
sourceDev: params.sourceIdentity.dev,
|
|
130
124
|
sourceIno: params.sourceIdentity.ino,
|
|
131
125
|
sourcePath: params.sourcePath,
|
|
132
126
|
},
|
|
133
127
|
});
|
|
134
128
|
}
|
|
129
|
+
async function runPinnedWriteFallbackOrThrow(params, cause) {
|
|
130
|
+
if (process.platform !== "win32") {
|
|
131
|
+
throw new FsSafeError("helper-unavailable", "Python helper is required for pinned writes on this platform", { cause });
|
|
132
|
+
}
|
|
133
|
+
return await runPinnedWriteFallback(params);
|
|
134
|
+
}
|
|
135
135
|
async function runPinnedWriteFallback(params) {
|
|
136
136
|
const parentPath = params.relativeParentPath
|
|
137
137
|
? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
|
|
138
138
|
: params.rootPath;
|
|
139
|
-
const parentGuard = await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
|
|
140
139
|
if (params.mkdir) {
|
|
141
|
-
await
|
|
142
|
-
await fs.mkdir(parentPath, { recursive: true });
|
|
143
|
-
});
|
|
140
|
+
await mkdirPathComponentsWithGuards({ rootReal: params.rootPath, targetPath: parentPath });
|
|
144
141
|
}
|
|
142
|
+
const parentGuard = params.mkdir
|
|
143
|
+
? await createAsyncDirectoryGuard(parentPath)
|
|
144
|
+
: await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
|
|
145
145
|
const targetPath = path.join(parentPath, params.basename);
|
|
146
146
|
if (params.overwrite === false) {
|
|
147
147
|
let handle = await withAsyncDirectoryGuards([parentGuard], async () => await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode), {
|
|
@@ -163,7 +163,7 @@ async function runPinnedWriteFallback(params) {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
else {
|
|
166
|
-
await
|
|
166
|
+
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
|
|
167
167
|
}
|
|
168
168
|
const stat = await handle.stat();
|
|
169
169
|
created = false;
|
|
@@ -184,7 +184,9 @@ async function runPinnedWriteFallback(params) {
|
|
|
184
184
|
? fsSync.constants.O_NOFOLLOW
|
|
185
185
|
: 0);
|
|
186
186
|
let handle;
|
|
187
|
-
let
|
|
187
|
+
let tempStat;
|
|
188
|
+
let targetStat;
|
|
189
|
+
let renamed = false;
|
|
188
190
|
try {
|
|
189
191
|
handle = await fs.open(tempPath, tempFlags, params.mode);
|
|
190
192
|
if (params.input.kind === "buffer") {
|
|
@@ -197,27 +199,34 @@ async function runPinnedWriteFallback(params) {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
else {
|
|
200
|
-
|
|
201
|
-
writable.once("close", () => {
|
|
202
|
-
handleClosedByStream = true;
|
|
203
|
-
});
|
|
204
|
-
await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
|
|
202
|
+
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
|
|
205
203
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
tempStat = await handle.stat();
|
|
205
|
+
const tempPathStat = await fs.lstat(tempPath);
|
|
206
|
+
if (tempPathStat.isSymbolicLink() || !sameFileIdentity(tempPathStat, tempStat)) {
|
|
207
|
+
throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
|
|
209
208
|
}
|
|
209
|
+
const expectedTempStat = tempStat;
|
|
210
|
+
await handle.close().catch(() => undefined);
|
|
211
|
+
handle = undefined;
|
|
210
212
|
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
211
213
|
await fs.rename(tempPath, targetPath);
|
|
214
|
+
renamed = true;
|
|
215
|
+
targetStat = await fs.lstat(targetPath);
|
|
216
|
+
if (targetStat.isSymbolicLink() || !sameFileIdentity(targetStat, expectedTempStat)) {
|
|
217
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
218
|
+
}
|
|
212
219
|
});
|
|
213
220
|
}
|
|
214
221
|
catch (error) {
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
await handle?.close().catch(() => undefined);
|
|
223
|
+
if (!renamed) {
|
|
224
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
217
225
|
}
|
|
218
|
-
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
219
226
|
throw error;
|
|
220
227
|
}
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
if (!targetStat) {
|
|
229
|
+
throw new FsSafeError("path-mismatch", "fallback target was not verified");
|
|
230
|
+
}
|
|
231
|
+
return { dev: targetStat.dev, ino: targetStat.ino };
|
|
223
232
|
}
|
package/dist/root-impl.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
|
package/dist/root-impl.js
CHANGED
|
@@ -20,6 +20,7 @@ import { resolveRootPath } from "./root-path.js";
|
|
|
20
20
|
import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
|
|
21
21
|
import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
|
|
22
22
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
23
|
+
import { stringifyJsonDocument } from "./json-stringify.js";
|
|
23
24
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
24
25
|
import { serializePathWrite } from "./write-queue.js";
|
|
25
26
|
function logWarn(message) {
|
|
@@ -260,12 +261,12 @@ class RootHandle {
|
|
|
260
261
|
}
|
|
261
262
|
async writeJson(relativePath, data, options = {}) {
|
|
262
263
|
const { replacer, space, trailingNewline = true, ...writeOptions } = options;
|
|
263
|
-
const json =
|
|
264
|
+
const json = stringifyJsonDocument(data, replacer, space);
|
|
264
265
|
await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
|
|
265
266
|
}
|
|
266
267
|
async createJson(relativePath, data, options = {}) {
|
|
267
268
|
const { replacer, space, trailingNewline = true, ...writeOptions } = options;
|
|
268
|
-
const json =
|
|
269
|
+
const json = stringifyJsonDocument(data, replacer, space);
|
|
269
270
|
await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
|
|
270
271
|
}
|
|
271
272
|
async copyIn(relativePath, sourcePath, options = {}) {
|
|
@@ -1050,6 +1051,9 @@ async function movePathFallback(root, params) {
|
|
|
1050
1051
|
if (sourceStat.isFile() && sourceStat.nlink > 1) {
|
|
1051
1052
|
throw new FsSafeError("hardlink", "hardlinked path not allowed");
|
|
1052
1053
|
}
|
|
1054
|
+
if (!params.overwrite && sourceStat.isDirectory()) {
|
|
1055
|
+
throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
|
|
1056
|
+
}
|
|
1053
1057
|
if (!params.overwrite) {
|
|
1054
1058
|
try {
|
|
1055
1059
|
await fs.lstat(target.resolved);
|
package/dist/root-path.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { isNotFoundPathError, isPathInside } from "./path.js";
|
|
5
|
+
import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
|
|
6
6
|
export const ROOT_PATH_ALIAS_POLICIES = {
|
|
7
7
|
strict: Object.freeze({
|
|
8
8
|
allowFinalSymlinkForUnlink: false,
|
|
@@ -531,7 +531,7 @@ function relativeInsideRoot(rootPath, targetPath) {
|
|
|
531
531
|
if (!relative || relative === ".") {
|
|
532
532
|
return "";
|
|
533
533
|
}
|
|
534
|
-
if (
|
|
534
|
+
if (isPathRelativeEscape(relative)) {
|
|
535
535
|
return "";
|
|
536
536
|
}
|
|
537
537
|
return relative;
|
package/dist/root-paths.js
CHANGED
|
@@ -62,7 +62,7 @@ export function resolvePathWithinRoot(params) {
|
|
|
62
62
|
}
|
|
63
63
|
const resolved = path.resolve(root, raw);
|
|
64
64
|
const rel = path.relative(root, resolved);
|
|
65
|
-
if (!rel || rel.startsWith(
|
|
65
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
66
66
|
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
|
|
67
67
|
}
|
|
68
68
|
return { ok: true, path: resolved };
|
|
@@ -118,7 +118,7 @@ async function resolveNearestExistingPath(targetPath) {
|
|
|
118
118
|
}
|
|
119
119
|
async function assertNoSymlinkSegments(params) {
|
|
120
120
|
const relative = path.relative(params.rootDir, params.targetPath);
|
|
121
|
-
if (relative.startsWith(
|
|
121
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
122
122
|
throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
|
|
123
123
|
}
|
|
124
124
|
let current = params.rootDir;
|
|
@@ -266,7 +266,10 @@ async function resolveCheckedPathsWithinRoot(params, allowMissingFallback) {
|
|
|
266
266
|
const rootDir = path.resolve(params.rootDir);
|
|
267
267
|
const rootRealPath = await resolveRealPathIfExists(rootDir);
|
|
268
268
|
const root = rootRealPath ? await openRoot(rootDir) : undefined;
|
|
269
|
-
const isInRoot = (relativePath) => Boolean(relativePath) &&
|
|
269
|
+
const isInRoot = (relativePath) => Boolean(relativePath) &&
|
|
270
|
+
relativePath !== ".." &&
|
|
271
|
+
!relativePath.startsWith(`..${path.sep}`) &&
|
|
272
|
+
!path.isAbsolute(relativePath);
|
|
270
273
|
const resolveExistingRelativePath = async (requestedPath) => {
|
|
271
274
|
const raw = requestedPath.trim();
|
|
272
275
|
const lexicalPathResult = resolvePathWithinRoot({
|
|
@@ -316,6 +319,25 @@ async function resolveCheckedPathsWithinRoot(params, allowMissingFallback) {
|
|
|
316
319
|
}
|
|
317
320
|
catch (err) {
|
|
318
321
|
if (allowMissingFallback && err instanceof FsSafeError && err.code === "not-found") {
|
|
322
|
+
if (!rootRealPath) {
|
|
323
|
+
resolvedPaths.push(pathResult.fallbackPath);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
await assertNoSymlinkSegments({
|
|
328
|
+
rootDir,
|
|
329
|
+
targetPath: pathResult.fallbackPath,
|
|
330
|
+
scopeLabel: params.scopeLabel,
|
|
331
|
+
});
|
|
332
|
+
const existingPath = await resolveNearestExistingPath(pathResult.fallbackPath);
|
|
333
|
+
const existingRealPath = await fs.realpath(existingPath);
|
|
334
|
+
if (!isPathInside(rootRealPath, existingRealPath)) {
|
|
335
|
+
return invalidPath(params.scopeLabel);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return invalidPath(params.scopeLabel);
|
|
340
|
+
}
|
|
319
341
|
resolvedPaths.push(pathResult.fallbackPath);
|
|
320
342
|
continue;
|
|
321
343
|
}
|
|
@@ -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;CACzB,CAAC;AAqHF,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,CAMpB;
|
|
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;CACzB,CAAC;AAqHF,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,CAMpB;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,12 +1,12 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import fs from "node:fs";
|
|
3
2
|
import fsp from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
5
|
-
import { createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
4
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
6
5
|
import { FsSafeError } from "./errors.js";
|
|
7
|
-
import {
|
|
6
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
8
7
|
import { resolveHomeRelativePath } from "./home-dir.js";
|
|
9
8
|
import { openPinnedFileSync } from "./pinned-open.js";
|
|
9
|
+
import { runPinnedWriteHelper } from "./pinned-write.js";
|
|
10
10
|
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
|
|
11
11
|
export const PRIVATE_SECRET_DIR_MODE = 0o700;
|
|
12
12
|
export const PRIVATE_SECRET_FILE_MODE = 0o600;
|
|
@@ -128,15 +128,18 @@ export function tryReadSecretFileSync(filePath, label, options = {}) {
|
|
|
128
128
|
const result = readSecretFileOutcomeSync(filePath, label, options);
|
|
129
129
|
return result.ok ? result.secret : undefined;
|
|
130
130
|
}
|
|
131
|
+
function isRelativeEscape(relativePath) {
|
|
132
|
+
return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
133
|
+
}
|
|
131
134
|
function assertPathWithinRoot(rootDir, targetPath) {
|
|
132
135
|
const relative = path.relative(rootDir, targetPath);
|
|
133
|
-
if (!relative ||
|
|
136
|
+
if (!relative || isRelativeEscape(relative)) {
|
|
134
137
|
throw new Error(`Private secret path must stay under ${rootDir}.`);
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
function assertRealPathWithinRoot(rootDir, targetPath) {
|
|
138
141
|
const relative = path.relative(rootDir, targetPath);
|
|
139
|
-
if (
|
|
142
|
+
if (isRelativeEscape(relative)) {
|
|
140
143
|
throw new Error(`Private secret path must stay under ${rootDir}.`);
|
|
141
144
|
}
|
|
142
145
|
}
|
|
@@ -151,30 +154,62 @@ async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {
|
|
|
151
154
|
throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
157
|
+
async function enforcePrivateFileIdentityAndMode(resolvedPath, expectedIdentity, expectedMode) {
|
|
158
|
+
const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
|
|
159
|
+
const handle = await fsp.open(resolvedPath, fs.constants.O_RDONLY | noFollowFlag);
|
|
160
|
+
try {
|
|
161
|
+
const openedStat = await handle.stat();
|
|
162
|
+
if (!openedStat.isFile() || !sameFileIdentity(openedStat, expectedIdentity)) {
|
|
163
|
+
throw new FsSafeError("path-mismatch", "private secret file changed during write");
|
|
164
|
+
}
|
|
165
|
+
const pathStat = await fsp.lstat(resolvedPath);
|
|
166
|
+
if (pathStat.isSymbolicLink() || !sameFileIdentity(pathStat, openedStat)) {
|
|
167
|
+
throw new FsSafeError("path-mismatch", "private secret path changed during write");
|
|
168
|
+
}
|
|
169
|
+
if (process.platform !== "win32") {
|
|
170
|
+
await handle.chmod(expectedMode);
|
|
171
|
+
const chmodStat = await handle.stat();
|
|
172
|
+
const actualMode = chmodStat.mode & 0o777;
|
|
173
|
+
if (actualMode !== expectedMode) {
|
|
174
|
+
throw new Error(`Private secret file ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
|
|
175
|
+
}
|
|
176
|
+
const refreshedPathStat = await fsp.lstat(resolvedPath);
|
|
177
|
+
if (refreshedPathStat.isSymbolicLink() || !sameFileIdentity(refreshedPathStat, chmodStat)) {
|
|
178
|
+
throw new FsSafeError("path-mismatch", "private secret path changed during mode check");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await handle.close().catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
154
186
|
async function ensurePrivateDirectory(rootDir, targetDir, mode) {
|
|
155
187
|
const resolvedRoot = path.resolve(rootDir);
|
|
156
188
|
const resolvedTarget = path.resolve(targetDir);
|
|
189
|
+
await fsp.mkdir(resolvedRoot, { recursive: true, mode });
|
|
190
|
+
const rootStat = await fsp.lstat(resolvedRoot);
|
|
191
|
+
if (rootStat.isSymbolicLink()) {
|
|
192
|
+
throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
|
|
193
|
+
}
|
|
194
|
+
if (!rootStat.isDirectory()) {
|
|
195
|
+
throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
|
|
196
|
+
}
|
|
197
|
+
const rootGuard = await createAsyncDirectoryGuard(resolvedRoot);
|
|
198
|
+
await enforcePrivatePathMode(rootGuard.realPath, mode, "directory");
|
|
199
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
157
200
|
if (resolvedTarget === resolvedRoot) {
|
|
158
|
-
|
|
159
|
-
const rootStat = await fsp.lstat(resolvedRoot);
|
|
160
|
-
if (rootStat.isSymbolicLink()) {
|
|
161
|
-
throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
|
|
162
|
-
}
|
|
163
|
-
if (!rootStat.isDirectory()) {
|
|
164
|
-
throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
|
|
165
|
-
}
|
|
166
|
-
await enforcePrivatePathMode(resolvedRoot, mode, "directory");
|
|
167
|
-
return;
|
|
201
|
+
return { rootGuard, targetReal: rootGuard.realPath };
|
|
168
202
|
}
|
|
169
203
|
assertPathWithinRoot(resolvedRoot, resolvedTarget);
|
|
170
|
-
|
|
171
|
-
const resolvedRootReal = await fsp.realpath(resolvedRoot);
|
|
204
|
+
const resolvedRootReal = rootGuard.realPath;
|
|
172
205
|
let current = resolvedRoot;
|
|
173
206
|
for (const segment of path
|
|
174
207
|
.relative(resolvedRoot, resolvedTarget)
|
|
175
208
|
.split(path.sep)
|
|
176
209
|
.filter(Boolean)) {
|
|
177
210
|
current = path.join(current, segment);
|
|
211
|
+
const parentGuard = await createAsyncDirectoryGuard(path.dirname(current));
|
|
212
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
178
213
|
try {
|
|
179
214
|
const stat = await fsp.lstat(current);
|
|
180
215
|
if (stat.isSymbolicLink()) {
|
|
@@ -188,12 +223,16 @@ async function ensurePrivateDirectory(rootDir, targetDir, mode) {
|
|
|
188
223
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
189
224
|
throw error;
|
|
190
225
|
}
|
|
226
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
191
227
|
await fsp.mkdir(current, { mode });
|
|
192
228
|
}
|
|
193
229
|
const currentReal = await fsp.realpath(current);
|
|
194
230
|
assertRealPathWithinRoot(resolvedRootReal, currentReal);
|
|
195
231
|
await enforcePrivatePathMode(currentReal, mode, "directory");
|
|
232
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
233
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
196
234
|
}
|
|
235
|
+
return { rootGuard, targetReal: await fsp.realpath(resolvedTarget) };
|
|
197
236
|
}
|
|
198
237
|
export async function writeSecretFileAtomic(params) {
|
|
199
238
|
const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
|
|
@@ -202,13 +241,12 @@ export async function writeSecretFileAtomic(params) {
|
|
|
202
241
|
const resolvedFile = path.resolve(params.filePath);
|
|
203
242
|
assertPathWithinRoot(resolvedRoot, resolvedFile);
|
|
204
243
|
const intendedParentDir = path.dirname(resolvedFile);
|
|
205
|
-
await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const parentGuard = await createAsyncDirectoryGuard(parentDir);
|
|
244
|
+
const { rootGuard, targetReal } = await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
|
|
245
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
246
|
+
assertRealPathWithinRoot(rootGuard.realPath, targetReal);
|
|
247
|
+
const parentGuard = await createAsyncDirectoryGuard(targetReal);
|
|
210
248
|
const fileName = path.basename(resolvedFile);
|
|
211
|
-
const finalFilePath = path.join(
|
|
249
|
+
const finalFilePath = path.join(targetReal, fileName);
|
|
212
250
|
try {
|
|
213
251
|
const stat = await fsp.lstat(finalFilePath);
|
|
214
252
|
if (stat.isSymbolicLink()) {
|
|
@@ -223,31 +261,19 @@ export async function writeSecretFileAtomic(params) {
|
|
|
223
261
|
throw error;
|
|
224
262
|
}
|
|
225
263
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
243
|
-
await fsp.rename(tempPath, finalFilePath);
|
|
244
|
-
});
|
|
245
|
-
createdTemp = false;
|
|
246
|
-
await enforcePrivatePathMode(finalFilePath, mode, "file");
|
|
247
|
-
}
|
|
248
|
-
finally {
|
|
249
|
-
if (createdTemp) {
|
|
250
|
-
await fsp.unlink(tempPath).catch(() => undefined);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
264
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
265
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
266
|
+
const identity = await runPinnedWriteHelper({
|
|
267
|
+
rootPath: parentGuard.realPath,
|
|
268
|
+
relativeParentPath: "",
|
|
269
|
+
basename: fileName,
|
|
270
|
+
mkdir: false,
|
|
271
|
+
mode,
|
|
272
|
+
overwrite: true,
|
|
273
|
+
input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
|
|
274
|
+
rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
|
|
275
|
+
});
|
|
276
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
277
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
278
|
+
await enforcePrivateFileIdentityAndMode(finalFilePath, identity, mode);
|
|
253
279
|
}
|
|
@@ -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;AAUrC,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;
|
|
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;AAUrC,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;AAsKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
|
package/dist/secure-file.js
CHANGED
|
@@ -9,7 +9,9 @@ import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadab
|
|
|
9
9
|
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
10
10
|
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
11
11
|
function isAbsolutePathname(value) {
|
|
12
|
-
return path.isAbsolute(value) ||
|
|
12
|
+
return (path.isAbsolute(value) ||
|
|
13
|
+
(process.platform === "win32" &&
|
|
14
|
+
(isWindowsDriveLetterPath(value, "win32") || isWindowsNetworkPath(value, "win32"))));
|
|
13
15
|
}
|
|
14
16
|
function label(options) {
|
|
15
17
|
return options.label ?? "Secure file";
|
package/dist/temp-target.js
CHANGED
|
@@ -52,7 +52,7 @@ async function cleanupTempDir(dir, onCleanupError) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function resolveTempRoot(rootDir) {
|
|
55
|
-
return rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" });
|
|
55
|
+
return path.resolve(rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" }));
|
|
56
56
|
}
|
|
57
57
|
export async function tempFile(params) {
|
|
58
58
|
const rootDir = resolveTempRoot(params.rootDir);
|
package/dist/timing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,GAAE,MAAM,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAErF,GACA,OAAO,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,GAAE,MAAM,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAErF,GACA,OAAO,CAAC,CAAC,CAAC,CA6BZ"}
|
package/dist/timing.js
CHANGED
|
@@ -12,7 +12,14 @@ export async function withTimeout(promise, timeoutMs, labelOrOptions = {
|
|
|
12
12
|
return await Promise.race([
|
|
13
13
|
promise,
|
|
14
14
|
new Promise((_, reject) => {
|
|
15
|
-
timeoutId = setTimeout(() =>
|
|
15
|
+
timeoutId = setTimeout(() => {
|
|
16
|
+
try {
|
|
17
|
+
reject(createError());
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
reject(error);
|
|
21
|
+
}
|
|
22
|
+
}, timeoutMs);
|
|
16
23
|
}),
|
|
17
24
|
]);
|
|
18
25
|
}
|
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, fail-closed stale-lock handling, and opt-in caller-approved stale removal. |
|
|
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";
|
|
56
|
+
staleRecovery?: "fail-closed" | "remove-if-unchanged";
|
|
57
57
|
managerKey?: string; // default `fs-safe.json-store:<filePath>`
|
|
58
58
|
};
|
|
59
59
|
|
|
@@ -141,6 +141,8 @@ 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.
|
|
145
|
+
|
|
144
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.
|
|
145
147
|
|
|
146
148
|
## Common patterns
|
package/docs/python-helper.md
CHANGED
|
@@ -62,12 +62,12 @@ Node-only mode still keeps the important application-level guardrails:
|
|
|
62
62
|
- root-relative path validation;
|
|
63
63
|
- canonical root checks;
|
|
64
64
|
- no-follow opens where Node/platform support exists;
|
|
65
|
-
- file identity checks around reads and writes;
|
|
66
|
-
- atomic sibling-temp replacement;
|
|
65
|
+
- file identity checks around reads and writes where a safe Node fallback exists;
|
|
66
|
+
- atomic sibling-temp replacement on fallback platforms;
|
|
67
67
|
- hardlink/symlink policy checks where the API requests them;
|
|
68
68
|
- byte limits and structured `FsSafeError` failures.
|
|
69
69
|
|
|
70
|
-
What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation.
|
|
70
|
+
What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Write paths that need fd-relative parent commits now fail closed when the helper is disabled or unavailable; other operations such as `root().move()`, `root().remove()`, and `root().mkdir()` may still rely on Node path operations plus pre/post checks instead of parent-fd syscalls.
|
|
71
71
|
|
|
72
72
|
That is usually acceptable when the root directory is only writable by the trusted application user. It is not the right posture if untrusted local processes can race writes in the same tree and you are relying on `fs-safe` as part of the security boundary.
|
|
73
73
|
|