@openclaw/fs-safe 0.1.2 → 0.2.0
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 +41 -0
- package/README.md +39 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +2 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +11 -7
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +60 -53
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +57 -51
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -1
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +10 -0
- package/docs/atomic.md +33 -5
- package/docs/config.md +32 -3
- package/docs/index.md +3 -2
- package/docs/json-store.md +4 -0
- package/docs/json.md +34 -0
- package/docs/output.md +92 -0
- package/docs/sidecar-lock.md +13 -12
- package/docs/store.md +33 -0
- package/docs/temp.md +10 -2
- package/docs/testing.md +10 -0
- package/package.json +7 -2
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
|
@@ -2,16 +2,20 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { Transform } from "node:stream";
|
|
6
5
|
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { createBoundedReadStream } from "./bounded-read-stream.js";
|
|
7
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
7
8
|
import { FsSafeError } from "./errors.js";
|
|
8
9
|
import { sameFileIdentity } from "./file-identity.js";
|
|
10
|
+
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
11
|
+
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
9
12
|
import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
|
|
10
13
|
import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
|
|
11
14
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
12
15
|
import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
|
|
13
16
|
import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
14
17
|
import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
|
|
18
|
+
import { pathStatFromStats } from "./path-stat.js";
|
|
15
19
|
import { resolveRootPath } from "./root-path.js";
|
|
16
20
|
import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
|
|
17
21
|
import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
|
|
@@ -433,24 +437,6 @@ function buildAtomicWriteTempPath(targetPath) {
|
|
|
433
437
|
function rootWriteQueueKey(root, relativePath) {
|
|
434
438
|
return `${root.rootReal}\0${relativePath}`;
|
|
435
439
|
}
|
|
436
|
-
function createMaxBytesTransform(maxBytes) {
|
|
437
|
-
let bytes = 0;
|
|
438
|
-
return new Transform({
|
|
439
|
-
transform(chunk, _encoding, callback) {
|
|
440
|
-
const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
|
|
441
|
-
bytes += buffer.byteLength;
|
|
442
|
-
if (bytes > maxBytes) {
|
|
443
|
-
callback(new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
callback(null, buffer);
|
|
447
|
-
},
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
function createBoundedReadStream(opened, maxBytes) {
|
|
451
|
-
const stream = opened.handle.createReadStream();
|
|
452
|
-
return maxBytes === undefined ? stream : stream.pipe(createMaxBytesTransform(maxBytes));
|
|
453
|
-
}
|
|
454
440
|
async function writeTempFileForAtomicReplace(params) {
|
|
455
441
|
const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
|
|
456
442
|
try {
|
|
@@ -567,7 +553,10 @@ async function openWritableFileInRoot(root, params) {
|
|
|
567
553
|
throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
|
|
568
554
|
}
|
|
569
555
|
if (params.mkdir !== false) {
|
|
570
|
-
await
|
|
556
|
+
const parentGuard = await createNearestExistingDirectoryGuard(rootReal, path.dirname(resolved));
|
|
557
|
+
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
558
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
559
|
+
});
|
|
571
560
|
}
|
|
572
561
|
let ioPath = resolved;
|
|
573
562
|
try {
|
|
@@ -965,25 +954,17 @@ async function resolvePinnedRootPathInRoot(root, params) {
|
|
|
965
954
|
};
|
|
966
955
|
}
|
|
967
956
|
async function removePathFallback(resolved) {
|
|
968
|
-
await
|
|
957
|
+
const guard = await createAsyncDirectoryGuard(path.dirname(resolved.resolved));
|
|
958
|
+
await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", resolved.resolved);
|
|
959
|
+
await assertAsyncDirectoryGuard(guard);
|
|
960
|
+
await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
|
|
961
|
+
await assertAsyncDirectoryGuard(guard).catch(() => undefined);
|
|
969
962
|
}
|
|
970
963
|
async function mkdirPathFallback(resolved) {
|
|
971
|
-
await
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
dev: Number(stat.dev),
|
|
976
|
-
gid: Number(stat.gid),
|
|
977
|
-
ino: Number(stat.ino),
|
|
978
|
-
isDirectory: stat.isDirectory(),
|
|
979
|
-
isFile: stat.isFile(),
|
|
980
|
-
isSymbolicLink: stat.isSymbolicLink(),
|
|
981
|
-
mode: stat.mode,
|
|
982
|
-
mtimeMs: stat.mtimeMs,
|
|
983
|
-
nlink: stat.nlink,
|
|
984
|
-
size: stat.size,
|
|
985
|
-
uid: stat.uid,
|
|
986
|
-
};
|
|
964
|
+
await mkdirPathComponentsWithGuards({
|
|
965
|
+
rootReal: resolved.rootReal, targetPath: resolved.resolved,
|
|
966
|
+
beforeComponent: async (componentPath) => await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("mkdir", componentPath),
|
|
967
|
+
});
|
|
987
968
|
}
|
|
988
969
|
async function statPathFallback(root, relativePath) {
|
|
989
970
|
const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
|
|
@@ -1080,6 +1061,11 @@ async function movePathFallback(root, params) {
|
|
|
1080
1061
|
}
|
|
1081
1062
|
}
|
|
1082
1063
|
}
|
|
1064
|
+
const sourceParentGuard = await createAsyncDirectoryGuard(path.dirname(source.resolved));
|
|
1065
|
+
const targetParentGuard = await createNearestExistingDirectoryGuard(target.rootReal, path.dirname(target.resolved));
|
|
1066
|
+
await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("move", target.resolved);
|
|
1067
|
+
await assertAsyncDirectoryGuard(sourceParentGuard);
|
|
1068
|
+
await assertAsyncDirectoryGuard(targetParentGuard);
|
|
1083
1069
|
try {
|
|
1084
1070
|
await fs.rename(source.resolved, target.resolved);
|
|
1085
1071
|
}
|
|
@@ -1096,6 +1082,7 @@ async function movePathFallback(root, params) {
|
|
|
1096
1082
|
}
|
|
1097
1083
|
throw error;
|
|
1098
1084
|
}
|
|
1085
|
+
await assertAsyncDirectoryGuard(targetParentGuard).catch(() => undefined);
|
|
1099
1086
|
}
|
|
1100
1087
|
async function writeFileFallback(root, params) {
|
|
1101
1088
|
if (params.overwrite === false) {
|
|
@@ -1111,6 +1098,7 @@ async function writeFileFallback(root, params) {
|
|
|
1111
1098
|
const destinationPath = target.realPath;
|
|
1112
1099
|
const mode = params.mode ?? (target.stat.mode & 0o777);
|
|
1113
1100
|
await target.handle.close().catch(() => { });
|
|
1101
|
+
const destinationGuard = await createAsyncDirectoryGuard(path.dirname(destinationPath));
|
|
1114
1102
|
let tempPath = null;
|
|
1115
1103
|
let unregisterTempPath = null;
|
|
1116
1104
|
try {
|
|
@@ -1122,7 +1110,10 @@ async function writeFileFallback(root, params) {
|
|
|
1122
1110
|
encoding: params.encoding,
|
|
1123
1111
|
mode: mode || 0o600,
|
|
1124
1112
|
});
|
|
1125
|
-
|
|
1113
|
+
const commitTempPath = tempPath;
|
|
1114
|
+
await withAsyncDirectoryGuards([destinationGuard], async () => {
|
|
1115
|
+
await fs.rename(commitTempPath, destinationPath);
|
|
1116
|
+
});
|
|
1126
1117
|
tempPath = null;
|
|
1127
1118
|
unregisterTempPath();
|
|
1128
1119
|
unregisterTempPath = null;
|
|
@@ -1160,20 +1151,32 @@ async function writeMissingFileFallback(root, params) {
|
|
|
1160
1151
|
if (params.mkdir !== false) {
|
|
1161
1152
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
1162
1153
|
}
|
|
1163
|
-
|
|
1154
|
+
const parentGuard = await createAsyncDirectoryGuard(path.dirname(resolved));
|
|
1164
1155
|
let created = false;
|
|
1165
1156
|
try {
|
|
1166
|
-
handle = await
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1157
|
+
const { handle, writtenStat } = await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
1158
|
+
const handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
|
|
1159
|
+
created = true;
|
|
1160
|
+
try {
|
|
1161
|
+
if (typeof params.data === "string") {
|
|
1162
|
+
await handle.writeFile(params.data, params.encoding ?? "utf8");
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
await handle.writeFile(params.data);
|
|
1166
|
+
}
|
|
1167
|
+
return { handle, writtenStat: await handle.stat() };
|
|
1168
|
+
}
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
await handle.close().catch(() => undefined);
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
}, {
|
|
1174
|
+
onPostGuardFailure: async ({ handle }) => {
|
|
1175
|
+
created = false; // Parent is untrusted now; skip outer path cleanup by name.
|
|
1176
|
+
await handle.close().catch(() => undefined);
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1175
1179
|
await handle.close();
|
|
1176
|
-
handle = null;
|
|
1177
1180
|
await verifyAtomicWriteResult({
|
|
1178
1181
|
root,
|
|
1179
1182
|
targetPath: resolved,
|
|
@@ -1190,7 +1193,6 @@ async function writeMissingFileFallback(root, params) {
|
|
|
1190
1193
|
throw err;
|
|
1191
1194
|
}
|
|
1192
1195
|
finally {
|
|
1193
|
-
await handle?.close().catch(() => undefined);
|
|
1194
1196
|
if (created) {
|
|
1195
1197
|
await fs.rm(resolved, { force: true }).catch(() => undefined);
|
|
1196
1198
|
}
|
|
@@ -1215,6 +1217,7 @@ async function copyFileFallback(root, params, source) {
|
|
|
1215
1217
|
const mode = params.mode ?? (target.stat.mode & 0o777);
|
|
1216
1218
|
await target.handle.close().catch(() => { });
|
|
1217
1219
|
targetClosedByUs = true;
|
|
1220
|
+
const destinationGuard = await createAsyncDirectoryGuard(path.dirname(destinationPath));
|
|
1218
1221
|
tempPath = buildAtomicWriteTempPath(destinationPath);
|
|
1219
1222
|
unregisterTempPath = registerTempPathForExit(tempPath);
|
|
1220
1223
|
tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600);
|
|
@@ -1233,7 +1236,10 @@ async function copyFileFallback(root, params, source) {
|
|
|
1233
1236
|
tempClosedByStream = true;
|
|
1234
1237
|
}
|
|
1235
1238
|
tempHandle = null;
|
|
1236
|
-
|
|
1239
|
+
const commitTempPath = tempPath;
|
|
1240
|
+
await withAsyncDirectoryGuards([destinationGuard], async () => {
|
|
1241
|
+
await fs.rename(commitTempPath, destinationPath);
|
|
1242
|
+
});
|
|
1237
1243
|
tempPath = null;
|
|
1238
1244
|
unregisterTempPath();
|
|
1239
1245
|
unregisterTempPath = null;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type SafePathSegmentOptions = {
|
|
2
|
+
allowDotPrefix?: boolean;
|
|
3
|
+
label?: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function isSafePathSegment(segment: string, options?: SafePathSegmentOptions): boolean;
|
|
6
|
+
export declare function assertSafePathSegment(segment: string, options?: SafePathSegmentOptions): string;
|
|
7
|
+
export declare function sanitizeSafePathSegment(value: string, fallback: string, options?: SafePathSegmentOptions): string;
|
|
8
|
+
export declare function assertSafePathPrefix(prefix: string, options?: SafePathSegmentOptions): string;
|
|
9
|
+
//# sourceMappingURL=safe-path-segment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-path-segment.d.ts","sourceRoot":"","sources":["../src/safe-path-segment.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,sBAAsB,GAAG;IACnC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAaT;AAED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAUR;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAWR;AAED,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,sBAA2B,GACnC,MAAM,CAeR"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
const SAFE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
3
|
+
const SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
4
|
+
export function isSafePathSegment(segment, options = {}) {
|
|
5
|
+
return (segment !== "" &&
|
|
6
|
+
segment !== "." &&
|
|
7
|
+
segment !== ".." &&
|
|
8
|
+
!segment.includes("/") &&
|
|
9
|
+
!segment.includes("\\") &&
|
|
10
|
+
!segment.includes("\0") &&
|
|
11
|
+
(options.allowDotPrefix === true || !segment.startsWith(".")) &&
|
|
12
|
+
(options.allowDotPrefix === true
|
|
13
|
+
? SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN.test(segment)
|
|
14
|
+
: SAFE_PATH_SEGMENT_PATTERN.test(segment)));
|
|
15
|
+
}
|
|
16
|
+
export function assertSafePathSegment(segment, options = {}) {
|
|
17
|
+
// Validate the exact value callers will later join into paths; trimming here
|
|
18
|
+
// would let whitespace-padded ids pass and then be used verbatim.
|
|
19
|
+
if (!isSafePathSegment(segment, options)) {
|
|
20
|
+
throw new FsSafeError("invalid-path", `${options.label ?? "path segment"} must be a safe path segment`);
|
|
21
|
+
}
|
|
22
|
+
return segment;
|
|
23
|
+
}
|
|
24
|
+
export function sanitizeSafePathSegment(value, fallback, options = {}) {
|
|
25
|
+
const sanitized = value
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/[\\/]+/g, "-")
|
|
28
|
+
.replace(/\0/g, "")
|
|
29
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "");
|
|
31
|
+
if (isSafePathSegment(sanitized, options)) {
|
|
32
|
+
return sanitized;
|
|
33
|
+
}
|
|
34
|
+
return assertSafePathSegment(fallback, { ...options, label: "fallback path segment" });
|
|
35
|
+
}
|
|
36
|
+
export function assertSafePathPrefix(prefix, options = {}) {
|
|
37
|
+
// Prefixes are often derived from safe filenames. Normalize harmless
|
|
38
|
+
// filename characters first, but still reject real path-control bytes.
|
|
39
|
+
if (prefix.includes("/") || prefix.includes("\\") || prefix.includes("\0")) {
|
|
40
|
+
return assertSafePathSegment(prefix, {
|
|
41
|
+
allowDotPrefix: true,
|
|
42
|
+
...options,
|
|
43
|
+
label: options.label ?? "path prefix",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return assertSafePathSegment(prefix.replace(/[^A-Za-z0-9._-]+/g, "-"), {
|
|
47
|
+
allowDotPrefix: true,
|
|
48
|
+
...options,
|
|
49
|
+
label: options.label ?? "path prefix",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -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":"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;AAuGF,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;AAoFD,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,CAyDhB"}
|
package/dist/secret-file.js
CHANGED
|
@@ -2,7 +2,9 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import fsp from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
5
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
6
8
|
import { resolveHomeRelativePath } from "./home-dir.js";
|
|
7
9
|
import { openPinnedFileSync } from "./pinned-open.js";
|
|
8
10
|
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
|
|
@@ -188,6 +190,7 @@ export async function writeSecretFileAtomic(params) {
|
|
|
188
190
|
const resolvedRootReal = await fsp.realpath(resolvedRoot);
|
|
189
191
|
const parentDir = await fsp.realpath(intendedParentDir);
|
|
190
192
|
assertRealPathWithinRoot(resolvedRootReal, parentDir);
|
|
193
|
+
const parentGuard = await createAsyncDirectoryGuard(parentDir);
|
|
191
194
|
const fileName = path.basename(resolvedFile);
|
|
192
195
|
const finalFilePath = path.join(parentDir, fileName);
|
|
193
196
|
try {
|
|
@@ -220,7 +223,9 @@ export async function writeSecretFileAtomic(params) {
|
|
|
220
223
|
if (refreshedParentReal !== parentDir) {
|
|
221
224
|
throw new Error(`Private secret parent directory changed during write for ${finalFilePath}.`);
|
|
222
225
|
}
|
|
223
|
-
await
|
|
226
|
+
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
227
|
+
await fsp.rename(tempPath, finalFilePath);
|
|
228
|
+
});
|
|
224
229
|
createdTemp = false;
|
|
225
230
|
await enforcePrivatePathMode(finalFilePath, mode, "file");
|
|
226
231
|
}
|
package/dist/sibling-temp.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5C,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA0CF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAyCxC;AAkBD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8ChB"}
|
package/dist/sibling-temp.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import crypto, { randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
5
|
+
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
4
6
|
import { sanitizeUntrustedFileName } from "./filename.js";
|
|
5
7
|
import { root } from "./root.js";
|
|
8
|
+
import { assertSafePathPrefix } from "./safe-path-segment.js";
|
|
9
|
+
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
6
10
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
11
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
7
12
|
import { serializePathWrite } from "./write-queue.js";
|
|
8
13
|
function buildTempPath(dir, tempPrefix) {
|
|
9
|
-
|
|
14
|
+
const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-stream", {
|
|
15
|
+
label: "sibling temp prefix",
|
|
16
|
+
});
|
|
17
|
+
return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
|
|
10
18
|
}
|
|
11
19
|
async function syncFileBestEffort(filePath) {
|
|
12
20
|
const handle = await fs.open(filePath, "r+");
|
|
@@ -45,7 +53,10 @@ function assertFinalPathIsSibling(dir, filePath) {
|
|
|
45
53
|
export async function writeSiblingTempFile(options) {
|
|
46
54
|
const dir = path.resolve(options.dir);
|
|
47
55
|
await fs.mkdir(dir, { recursive: true, mode: options.dirMode ?? 0o700 });
|
|
48
|
-
|
|
56
|
+
if (options.chmodDir !== false) {
|
|
57
|
+
await fs.chmod(dir, options.dirMode ?? 0o700).catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
const dirGuard = await createAsyncDirectoryGuard(dir);
|
|
49
60
|
const tempPath = buildTempPath(dir, options.tempPrefix);
|
|
50
61
|
const unregisterTempPath = registerTempPathForExit(tempPath);
|
|
51
62
|
let tempExists = false;
|
|
@@ -61,7 +72,9 @@ export async function writeSiblingTempFile(options) {
|
|
|
61
72
|
const filePath = path.resolve(options.resolveFinalPath(result));
|
|
62
73
|
assertFinalPathIsSibling(dir, filePath);
|
|
63
74
|
await serializePathWrite(filePath, async () => {
|
|
64
|
-
await
|
|
75
|
+
await withAsyncDirectoryGuards([dirGuard], async () => {
|
|
76
|
+
await fs.rename(tempPath, filePath);
|
|
77
|
+
});
|
|
65
78
|
tempExists = false;
|
|
66
79
|
unregisterTempPath();
|
|
67
80
|
if (options.mode !== undefined) {
|
|
@@ -82,8 +95,11 @@ export async function writeSiblingTempFile(options) {
|
|
|
82
95
|
}
|
|
83
96
|
function buildSiblingTempPath(params) {
|
|
84
97
|
const id = crypto.randomUUID();
|
|
98
|
+
const safePrefix = assertSafePathPrefix(params.tempPrefix, {
|
|
99
|
+
label: "sibling temp prefix",
|
|
100
|
+
});
|
|
85
101
|
const safeTail = sanitizeUntrustedFileName(path.basename(params.targetPath), params.fallbackFileName);
|
|
86
|
-
return path.join(path.dirname(params.targetPath), `${
|
|
102
|
+
return path.join(path.dirname(params.targetPath), `${safePrefix}${id}-${safeTail}.part`);
|
|
87
103
|
}
|
|
88
104
|
export async function writeViaSiblingTempPath(params) {
|
|
89
105
|
const rootDir = await fs
|
|
@@ -101,19 +117,28 @@ export async function writeViaSiblingTempPath(params) {
|
|
|
101
117
|
path.isAbsolute(relativeTargetPath)) {
|
|
102
118
|
throw new Error("Target path is outside the allowed root");
|
|
103
119
|
}
|
|
120
|
+
const rootGuard = await createAsyncDirectoryGuard(rootDir);
|
|
121
|
+
const tempDir = await fs.mkdtemp(path.join(resolveSecureTempRoot({
|
|
122
|
+
fallbackPrefix: "fs-safe-output",
|
|
123
|
+
unsafeFallbackLabel: "sibling temp output dir",
|
|
124
|
+
warn: () => undefined,
|
|
125
|
+
}), "fs-safe-output-"));
|
|
104
126
|
const tempPath = buildSiblingTempPath({
|
|
105
|
-
targetPath,
|
|
127
|
+
targetPath: path.join(tempDir, path.basename(targetPath)),
|
|
106
128
|
fallbackFileName: params.fallbackFileName ?? "output.bin",
|
|
107
129
|
tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
|
|
108
130
|
});
|
|
109
|
-
const unregisterTempPath = registerTempPathForExit(
|
|
131
|
+
const unregisterTempPath = registerTempPathForExit(tempDir, { recursive: true });
|
|
110
132
|
try {
|
|
133
|
+
await getFsSafeTestHooks()?.beforeSiblingTempWrite?.(tempPath);
|
|
111
134
|
await params.writeTemp(tempPath);
|
|
135
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
112
136
|
const targetRoot = await root(rootDir);
|
|
113
137
|
await targetRoot.copyIn(relativeTargetPath, tempPath, { mkdir: false });
|
|
138
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
114
139
|
}
|
|
115
140
|
finally {
|
|
116
|
-
await fs.rm(
|
|
141
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
117
142
|
unregisterTempPath();
|
|
118
143
|
}
|
|
119
144
|
}
|
package/dist/sidecar-lock.d.ts
CHANGED
|
@@ -5,12 +5,14 @@ export type SidecarLockRetryOptions = {
|
|
|
5
5
|
maxTimeout?: number;
|
|
6
6
|
randomize?: boolean;
|
|
7
7
|
};
|
|
8
|
+
export type SidecarLockStaleRecovery = "fail-closed";
|
|
8
9
|
export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
9
10
|
targetPath: string;
|
|
10
11
|
lockPath?: string;
|
|
11
12
|
staleMs: number;
|
|
12
13
|
timeoutMs?: number;
|
|
13
14
|
retry?: SidecarLockRetryOptions;
|
|
15
|
+
staleRecovery?: SidecarLockStaleRecovery;
|
|
14
16
|
allowReentrant?: boolean;
|
|
15
17
|
payload: () => TPayload | Promise<TPayload>;
|
|
16
18
|
shouldReclaim?: (params: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"
|
|
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
|
@@ -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 { sameFileIdentity } from "./file-identity.js";
|
|
4
5
|
const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
|
|
5
6
|
function getGlobalManagers() {
|
|
6
7
|
const globalWithState = globalThis;
|
|
@@ -18,17 +19,63 @@ function resolveManagerState(key) {
|
|
|
18
19
|
}
|
|
19
20
|
return state;
|
|
20
21
|
}
|
|
21
|
-
async function
|
|
22
|
+
async function readLockSnapshot(lockPath) {
|
|
22
23
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const stat = await fs.lstat(lockPath);
|
|
25
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
29
|
+
? parsed
|
|
30
|
+
: null;
|
|
31
|
+
return { raw, payload, stat };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { raw, payload: null, stat };
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
catch {
|
|
29
38
|
return null;
|
|
30
39
|
}
|
|
31
40
|
}
|
|
41
|
+
function snapshotMatches(current, observed) {
|
|
42
|
+
if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (observed.raw !== undefined) {
|
|
46
|
+
return current.raw === observed.raw;
|
|
47
|
+
}
|
|
48
|
+
return observed.stat !== undefined && current.stat !== undefined;
|
|
49
|
+
}
|
|
50
|
+
async function removeLockIfUnchanged(lockPath, observed) {
|
|
51
|
+
const current = await readLockSnapshot(lockPath);
|
|
52
|
+
if (!current || !observed) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!snapshotMatches(current, observed)) {
|
|
56
|
+
// The lock changed after we decided it was stale. Leave the fresh holder's
|
|
57
|
+
// file alone; deleting by path here would break mutual exclusion.
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
async function lockSnapshotStillPresent(lockPath, observed) {
|
|
64
|
+
const current = await readLockSnapshot(lockPath);
|
|
65
|
+
return !!current && !!observed && snapshotMatches(current, observed);
|
|
66
|
+
}
|
|
67
|
+
function snapshotMatchesSync(lockPath, observed) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = fsSync.lstatSync(lockPath);
|
|
70
|
+
if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
32
79
|
async function resolveNormalizedTargetPath(targetPath) {
|
|
33
80
|
const resolved = path.resolve(targetPath);
|
|
34
81
|
const dir = path.dirname(resolved);
|
|
@@ -66,7 +113,9 @@ function releaseAllLocksSync(state) {
|
|
|
66
113
|
for (const [normalizedTargetPath, held] of state.held) {
|
|
67
114
|
void held.handle.close().catch(() => undefined);
|
|
68
115
|
try {
|
|
69
|
-
|
|
116
|
+
if (snapshotMatchesSync(held.lockPath, held.snapshot)) {
|
|
117
|
+
fsSync.rmSync(held.lockPath, { force: true });
|
|
118
|
+
}
|
|
70
119
|
}
|
|
71
120
|
catch {
|
|
72
121
|
// Best-effort process-exit cleanup.
|
|
@@ -95,7 +144,7 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
|
|
|
95
144
|
state.held.delete(normalizedTargetPath);
|
|
96
145
|
held.releasePromise = (async () => {
|
|
97
146
|
await held.handle.close().catch(() => undefined);
|
|
98
|
-
await
|
|
147
|
+
await removeLockIfUnchanged(held.lockPath, held.snapshot);
|
|
99
148
|
})();
|
|
100
149
|
try {
|
|
101
150
|
await held.releasePromise;
|
|
@@ -137,15 +186,19 @@ export function createSidecarLockManager(key) {
|
|
|
137
186
|
let handle = null;
|
|
138
187
|
try {
|
|
139
188
|
handle = await fs.open(lockPath, "wx");
|
|
189
|
+
const payload = await options.payload();
|
|
190
|
+
const raw = `${JSON.stringify(payload, null, 2)}\n`;
|
|
191
|
+
await handle.writeFile(raw, "utf8");
|
|
192
|
+
const snapshot = { raw, payload, stat: await handle.stat() };
|
|
140
193
|
const createdHeld = {
|
|
141
194
|
count: 1,
|
|
142
195
|
handle,
|
|
143
196
|
lockPath,
|
|
197
|
+
snapshot,
|
|
144
198
|
acquiredAt: Date.now(),
|
|
145
199
|
metadata: options.metadata ?? {},
|
|
146
200
|
};
|
|
147
201
|
state.held.set(normalizedTargetPath, createdHeld);
|
|
148
|
-
await handle.writeFile(`${JSON.stringify(await options.payload(), null, 2)}\n`, "utf8");
|
|
149
202
|
const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
|
|
150
203
|
return {
|
|
151
204
|
lockPath,
|
|
@@ -156,29 +209,56 @@ export function createSidecarLockManager(key) {
|
|
|
156
209
|
}
|
|
157
210
|
catch (err) {
|
|
158
211
|
if (handle) {
|
|
212
|
+
const failedSnapshot = { payload: null };
|
|
213
|
+
try {
|
|
214
|
+
failedSnapshot.stat = await handle.stat();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Best-effort cleanup of a failed exclusive create.
|
|
218
|
+
}
|
|
159
219
|
const current = state.held.get(normalizedTargetPath);
|
|
160
220
|
if (current?.handle === handle) {
|
|
161
221
|
state.held.delete(normalizedTargetPath);
|
|
162
222
|
}
|
|
163
|
-
|
|
223
|
+
// If payload serialization/write fails, the file may be empty or
|
|
224
|
+
// partial JSON, so remove while our exclusive handle is still open.
|
|
164
225
|
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
226
|
+
await handle.close().catch(() => undefined);
|
|
227
|
+
// Windows can refuse removing an open file; retry after close but
|
|
228
|
+
// only if the path still points at the file identity we created.
|
|
229
|
+
await removeLockIfUnchanged(lockPath, failedSnapshot);
|
|
165
230
|
}
|
|
166
231
|
if (err.code !== "EEXIST") {
|
|
167
232
|
throw err;
|
|
168
233
|
}
|
|
169
234
|
const nowMs = Date.now();
|
|
170
|
-
const
|
|
235
|
+
const snapshot = await readLockSnapshot(lockPath);
|
|
236
|
+
if (!snapshot) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
171
239
|
const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
|
|
172
240
|
if (await shouldReclaim({
|
|
173
241
|
lockPath,
|
|
174
242
|
normalizedTargetPath,
|
|
175
|
-
payload,
|
|
243
|
+
payload: snapshot?.payload ?? null,
|
|
176
244
|
staleMs: options.staleMs,
|
|
177
245
|
nowMs,
|
|
178
246
|
heldByThisProcess: state.held.has(normalizedTargetPath),
|
|
179
247
|
})) {
|
|
180
|
-
await
|
|
181
|
-
|
|
248
|
+
if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// Node exposes only path-based unlink/rename here. A stale-lock
|
|
252
|
+
// reclaimer cannot bind the delete to the file it inspected, so a
|
|
253
|
+
// concurrent release+fresh-acquire could otherwise lose its lock.
|
|
254
|
+
// Fail closed and let callers choose a higher-level recovery path.
|
|
255
|
+
if ((options.staleRecovery ?? "fail-closed") === "fail-closed") {
|
|
256
|
+
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
|
257
|
+
code: "file_lock_stale",
|
|
258
|
+
lockPath,
|
|
259
|
+
normalizedTargetPath,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
182
262
|
}
|
|
183
263
|
const elapsed = Date.now() - startedAt;
|
|
184
264
|
if ((options.timeoutMs !== undefined &&
|
package/dist/store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { fileStore, fileStoreSync, type FileStore, type FileStoreOptions, type FileStorePruneOptions, type FileStoreReadOptions, type FileStoreSync, type FileStoreWriteOptions, } from "./file-store.js";
|
|
2
2
|
export { jsonStore, type JsonStore, type JsonFileStoreOptions, type JsonStoreLockOptions, type JsonStoreOptions, } from "./json-store.js";
|
|
3
|
+
export { ackJsonDurableQueueEntry, ensureJsonDurableQueueDirs, jsonDurableQueueEntryExists, loadJsonDurableQueueEntry, loadPendingJsonDurableQueueEntries, moveJsonDurableQueueEntryToFailed, readJsonDurableQueueEntry, resolveJsonDurableQueueEntryPaths, unlinkBestEffort, writeJsonDurableQueueEntry, type JsonDurableQueueEntryPaths, type JsonDurableQueueLoadOptions, type JsonDurableQueueReadResult, } from "./json-durable-queue.js";
|
|
3
4
|
//# sourceMappingURL=store.d.ts.map
|