@openclaw/fs-safe 0.5.1 → 0.5.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 +66 -0
- package/README.md +18 -6
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +20 -12
- package/dist/archive-entry.d.ts +1 -0
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +25 -5
- package/dist/archive-input.d.ts +7 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +11 -1
- package/dist/archive-limits.js +1 -1
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +6 -3
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +2 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +30 -12
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +3 -2
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +7 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +7 -4
- package/dist/archive-zip-entry.d.ts +1 -0
- package/dist/archive-zip-entry.d.ts.map +1 -1
- package/dist/archive-zip-integrity.d.ts +4 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -0
- package/dist/archive-zip-integrity.js +44 -0
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +6 -1
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +5 -2
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +13 -2
- package/dist/deny-mutations.d.ts +1 -0
- package/dist/deny-mutations.d.ts.map +1 -1
- package/dist/deny-mutations.js +6 -39
- package/dist/device-path.d.ts +1 -0
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +6 -6
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +5 -4
- package/dist/error-detail.d.ts +2 -0
- package/dist/error-detail.d.ts.map +1 -0
- package/dist/error-detail.js +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +4 -1
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +88 -35
- package/dist/file-store-boundary.d.ts +6 -0
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +20 -5
- package/dist/file-store-sync-write.d.ts +9 -0
- package/dist/file-store-sync-write.d.ts.map +1 -0
- package/dist/file-store-sync-write.js +97 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +36 -128
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +27 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +5 -6
- package/dist/install-path.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +19 -13
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +1 -3
- package/dist/local-file-access.d.ts +3 -2
- package/dist/local-file-access.d.ts.map +1 -1
- package/dist/local-file-access.js +12 -6
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +35 -30
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +51 -12
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +1 -0
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +14 -1
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +44 -17
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +6 -5
- package/dist/path-policy.d.ts.map +1 -1
- package/dist/path-policy.js +1 -7
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -0
- package/dist/permission-exec.d.ts +6 -0
- package/dist/permission-exec.d.ts.map +1 -0
- package/dist/permission-exec.js +28 -0
- package/dist/permissions-windows.d.ts +56 -0
- package/dist/permissions-windows.d.ts.map +1 -0
- package/dist/permissions-windows.js +285 -0
- package/dist/permissions.d.ts +3 -46
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +17 -328
- package/dist/pinned-write.js +2 -1
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +21 -2
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +10 -1
- package/dist/replace-file-copy-fallback.d.ts +3 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +18 -11
- package/dist/replace-file-descriptor.d.ts +33 -0
- package/dist/replace-file-descriptor.d.ts.map +1 -0
- package/dist/replace-file-descriptor.js +96 -0
- package/dist/replace-file.d.ts +9 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +64 -72
- package/dist/root-context.d.ts +8 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +43 -2
- package/dist/root-errors.d.ts +7 -0
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +46 -1
- package/dist/root-file.d.ts +1 -0
- package/dist/root-file.d.ts.map +1 -1
- package/dist/root-file.js +2 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +213 -168
- package/dist/root-path-symlink.d.ts +3 -0
- package/dist/root-path-symlink.d.ts.map +1 -0
- package/dist/root-path-symlink.js +35 -0
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +129 -190
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +15 -0
- package/dist/safe-path-segment.d.ts +2 -0
- package/dist/safe-path-segment.d.ts.map +1 -1
- package/dist/safe-path-segment.js +12 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +19 -3
- package/dist/secure-temp-dir.d.ts.map +1 -1
- package/dist/secure-temp-dir.js +6 -1
- package/dist/short-path.d.ts +2 -0
- package/dist/short-path.d.ts.map +1 -0
- package/dist/short-path.js +7 -0
- package/dist/sidecar-lock-acquire.d.ts +30 -0
- package/dist/sidecar-lock-acquire.d.ts.map +1 -0
- package/dist/sidecar-lock-acquire.js +260 -0
- package/dist/sidecar-lock-policy.d.ts +3 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +20 -3
- package/dist/sidecar-lock-reclaim.d.ts +5 -1
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +59 -9
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +27 -226
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +15 -4
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/timing.d.ts +2 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +9 -0
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +15 -0
- package/docs/advanced.md +1 -1
- package/docs/archive.md +27 -20
- package/docs/assets/readme-banner.jpg +0 -0
- package/docs/atomic.md +48 -19
- package/docs/errors.md +31 -7
- package/docs/file-store.md +10 -1
- package/docs/filename.md +17 -16
- package/docs/index.md +1 -0
- package/docs/install-path.md +9 -6
- package/docs/json.md +8 -2
- package/docs/local-roots.md +79 -103
- package/docs/native-helper.md +11 -5
- package/docs/native.md +2 -2
- package/docs/output.md +8 -3
- package/docs/path-scope.md +4 -1
- package/docs/path.md +7 -3
- package/docs/permissions.md +8 -0
- package/docs/public-api.md +109 -0
- package/docs/quickstart.md +6 -2
- package/docs/reading.md +13 -10
- package/docs/root.md +26 -4
- package/docs/secret-file.md +17 -4
- package/docs/security-model.md +21 -4
- package/docs/sidecar-lock.md +15 -5
- package/docs/temp.md +37 -5
- package/docs/test-hooks.md +2 -0
- package/docs/types.md +32 -15
- package/docs/writing.md +25 -12
- package/package.json +7 -3
- package/dist/archive-file-io.d.ts +0 -9
- package/dist/archive-file-io.d.ts.map +0 -1
- package/dist/archive-file-io.js +0 -11
- package/dist/file-store-source.d.ts +0 -5
- package/dist/file-store-source.d.ts.map +0 -1
- package/dist/file-store-source.js +0 -30
- package/dist/file-url.d.ts +0 -2
- package/dist/file-url.d.ts.map +0 -1
- package/dist/file-url.js +0 -1
- package/dist/path-stat.d.ts +0 -4
- package/dist/path-stat.d.ts.map +0 -1
- package/dist/path-stat.js +0 -15
- package/dist/windows-permissions-native.d.ts +0 -9
- package/dist/windows-permissions-native.d.ts.map +0 -1
- package/dist/windows-permissions-native.js +0 -31
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 { canonicalPathFromExistingAncestor } from "./absolute-path.js";
|
|
4
5
|
import { readFileDescriptorBoundedSync } from "./bounded-read.js";
|
|
5
6
|
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
6
7
|
import { FsSafeError } from "./errors.js";
|
|
@@ -8,6 +9,7 @@ import { sameFileIdentity } from "./file-identity.js";
|
|
|
8
9
|
import { resolveHomeRelativePath } from "./home-dir.js";
|
|
9
10
|
import { openPinnedFileSync } from "./pinned-open.js";
|
|
10
11
|
import { runPinnedWriteHelper } from "./pinned-write.js";
|
|
12
|
+
import { serializePathWrite } from "./write-queue.js";
|
|
11
13
|
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
|
|
12
14
|
export const PRIVATE_SECRET_DIR_MODE = 0o700;
|
|
13
15
|
export const PRIVATE_SECRET_FILE_MODE = 0o600;
|
|
@@ -89,7 +91,6 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
|
|
|
89
91
|
filePath: resolvedPath,
|
|
90
92
|
rejectPathSymlink: options.rejectSymlink,
|
|
91
93
|
rejectHardlinks: options.rejectHardlinks !== false,
|
|
92
|
-
maxBytes,
|
|
93
94
|
});
|
|
94
95
|
if (!opened.ok) {
|
|
95
96
|
const error = normalizeSecretReadError(opened.reason === "validation" ? new Error("security validation failed") : opened.error);
|
|
@@ -257,6 +258,15 @@ async function ensurePrivateDirectory(rootDir, targetDir, mode) {
|
|
|
257
258
|
}
|
|
258
259
|
return { rootGuard, targetReal: await fsp.realpath(resolvedTarget) };
|
|
259
260
|
}
|
|
261
|
+
async function secretFileWriteQueueKey(filePath) {
|
|
262
|
+
try {
|
|
263
|
+
return await canonicalPathFromExistingAncestor(filePath);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Keep validation and its public error shape owned by the write path below.
|
|
267
|
+
return path.resolve(filePath);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
260
270
|
async function materializeSecretFileAtomic(params, createOnly) {
|
|
261
271
|
const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
|
|
262
272
|
const dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;
|
|
@@ -304,11 +314,17 @@ async function materializeSecretFileAtomic(params, createOnly) {
|
|
|
304
314
|
await enforcePrivateFileIdentityAndMode(finalFilePath, identity, mode);
|
|
305
315
|
}
|
|
306
316
|
export async function writeSecretFileAtomic(params) {
|
|
307
|
-
await
|
|
317
|
+
const canonicalPath = await secretFileWriteQueueKey(params.filePath);
|
|
318
|
+
await serializePathWrite(canonicalPath, async () => {
|
|
319
|
+
await materializeSecretFileAtomic(params, false);
|
|
320
|
+
});
|
|
308
321
|
}
|
|
309
322
|
export async function createSecretFileAtomic(params) {
|
|
310
323
|
try {
|
|
311
|
-
await
|
|
324
|
+
const canonicalPath = await secretFileWriteQueueKey(params.filePath);
|
|
325
|
+
await serializePathWrite(canonicalPath, async () => {
|
|
326
|
+
await materializeSecretFileAtomic(params, true);
|
|
327
|
+
});
|
|
312
328
|
}
|
|
313
329
|
catch (error) {
|
|
314
330
|
if ((error instanceof FsSafeError && error.code === "already-exists") ||
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-temp-dir.d.ts","sourceRoot":"","sources":["../src/secure-temp-dir.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"secure-temp-dir.d.ts","sourceRoot":"","sources":["../src/secure-temp-dir.ts"],"names":[],"mappings":"AAOA,KAAK,aAAa,GAAG;IACnB,WAAW,IAAI,OAAO,CAAC;IACvB,cAAc,IAAI,OAAO,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,aAAa,CAAC;IAC5C,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAWF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,CA8JnF"}
|
package/dist/secure-temp-dir.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { tmpdir as getOsTmpDir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertSafePathSegment } from "./safe-path-segment.js";
|
|
4
5
|
function isNodeErrorWithCode(err, code) {
|
|
5
6
|
return (typeof err === "object" &&
|
|
6
7
|
err !== null &&
|
|
@@ -8,6 +9,10 @@ function isNodeErrorWithCode(err, code) {
|
|
|
8
9
|
err.code === code);
|
|
9
10
|
}
|
|
10
11
|
export function resolveSecureTempRoot(options) {
|
|
12
|
+
const fallbackPrefix = assertSafePathSegment(options.fallbackPrefix, {
|
|
13
|
+
allowDotPrefix: true,
|
|
14
|
+
label: "fallback temp prefix",
|
|
15
|
+
});
|
|
11
16
|
const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK;
|
|
12
17
|
const accessSync = options.accessSync ?? fs.accessSync;
|
|
13
18
|
const chmodSync = options.chmodSync ?? fs.chmodSync;
|
|
@@ -42,7 +47,7 @@ export function resolveSecureTempRoot(options) {
|
|
|
42
47
|
};
|
|
43
48
|
const fallback = () => {
|
|
44
49
|
const base = tmpdir();
|
|
45
|
-
const suffix = uid === undefined ?
|
|
50
|
+
const suffix = uid === undefined ? fallbackPrefix : `${fallbackPrefix}-${uid}`;
|
|
46
51
|
const joiner = platform === "win32" ? path.win32.join : path.join;
|
|
47
52
|
return joiner(base, suffix);
|
|
48
53
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"short-path.d.ts","sourceRoot":"","sources":["../src/short-path.ts"],"names":[],"mappings":"AAGA,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI/C"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { formatErrorDetail } from "./error-detail.js";
|
|
3
|
+
export function shortPath(value) {
|
|
4
|
+
const home = os.homedir();
|
|
5
|
+
const shortened = value.startsWith(home) ? `~${value.slice(home.length)}` : value;
|
|
6
|
+
return formatErrorDetail(shortened);
|
|
7
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type NativeFileHandle } from "./native-operations.js";
|
|
2
|
+
import type { Root } from "./root-impl.js";
|
|
3
|
+
import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
|
|
4
|
+
import type { SidecarLockAcquireOptions, SidecarLockHandle } from "./sidecar-lock-types.js";
|
|
5
|
+
type SidecarFileHandle = Pick<NativeFileHandle, "close" | "stat" | "writeFile">;
|
|
6
|
+
export type HeldSidecarLock = {
|
|
7
|
+
refCount: number;
|
|
8
|
+
reentrantOwner?: string;
|
|
9
|
+
handle: SidecarFileHandle;
|
|
10
|
+
lockPath: string;
|
|
11
|
+
snapshot: SidecarLockSnapshot;
|
|
12
|
+
acquiredAt: number;
|
|
13
|
+
metadata: Record<string, unknown>;
|
|
14
|
+
releasePromise?: Promise<void>;
|
|
15
|
+
lockRoot?: Root;
|
|
16
|
+
parsePayload?: (raw: string) => unknown;
|
|
17
|
+
compromiseTimer?: NodeJS.Timeout;
|
|
18
|
+
};
|
|
19
|
+
type SidecarLockAcquisitionContext = {
|
|
20
|
+
held: Map<string, HeldSidecarLock>;
|
|
21
|
+
reclaimGuards: Set<string>;
|
|
22
|
+
ensureExitCleanupRegistered(): void;
|
|
23
|
+
handleForHeldLock(normalizedTargetPath: string, held: HeldSidecarLock): SidecarLockHandle;
|
|
24
|
+
releaseHeldLock(normalizedTargetPath: string, held: HeldSidecarLock, options?: {
|
|
25
|
+
force?: boolean;
|
|
26
|
+
}): Promise<boolean>;
|
|
27
|
+
};
|
|
28
|
+
export declare function acquireSidecarLock<TPayload extends Record<string, unknown>>(options: SidecarLockAcquireOptions<TPayload>, context: SidecarLockAcquisitionContext): Promise<SidecarLockHandle>;
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=sidecar-lock-acquire.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sidecar-lock-acquire.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-acquire.ts"],"names":[],"mappings":"AAGA,OAAO,EAA6B,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1F,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAO3C,OAAO,EAUL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE5F,KAAK,iBAAiB,GAAG,IAAI,CAAC,gBAAgB,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC;AAEhF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,cAAc,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACxC,eAAe,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;CAClC,CAAC;AAEF,KAAK,6BAA6B,GAAG;IACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,2BAA2B,IAAI,IAAI,CAAC;IACpC,iBAAiB,CAAC,oBAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,iBAAiB,CAAC;IAC1F,eAAe,CACb,oBAAoB,EAAE,MAAM,EAC5B,IAAI,EAAE,eAAe,EACrB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB,CAAC;AAaF,wBAAsB,kBAAkB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,OAAO,EAAE,yBAAyB,CAAC,QAAQ,CAAC,EAC5C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,iBAAiB,CAAC,CAmP5B"}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
4
|
+
import { createNativeExclusiveFile } from "./native-operations.js";
|
|
5
|
+
import { computeSidecarLockDelayMs, defaultSidecarLockShouldReclaim, isTransientLockFileDenial, maxTransientLockDenials, } from "./sidecar-lock-policy.js";
|
|
6
|
+
import { readSidecarLockSnapshot, relativeSidecarLockPath, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
|
|
7
|
+
async function resolveNormalizedTargetPath(targetPath) {
|
|
8
|
+
const resolved = path.resolve(targetPath);
|
|
9
|
+
const dir = path.dirname(resolved);
|
|
10
|
+
await fs.mkdir(dir, { recursive: true });
|
|
11
|
+
try {
|
|
12
|
+
return path.join(await fs.realpath(dir), path.basename(resolved));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return resolved;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function acquireSidecarLock(options, context) {
|
|
19
|
+
context.ensureExitCleanupRegistered();
|
|
20
|
+
const normalizedTargetPath = await resolveNormalizedTargetPath(options.targetPath);
|
|
21
|
+
const lockPath = options.lockPath ?? `${normalizedTargetPath}.lock`;
|
|
22
|
+
const held = context.held.get(normalizedTargetPath);
|
|
23
|
+
if (held &&
|
|
24
|
+
options.reentrantOwner !== undefined &&
|
|
25
|
+
held.reentrantOwner !== undefined &&
|
|
26
|
+
options.reentrantOwner === held.reentrantOwner) {
|
|
27
|
+
held.refCount += 1;
|
|
28
|
+
return context.handleForHeldLock(normalizedTargetPath, held);
|
|
29
|
+
}
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const retry = options.retry ?? {};
|
|
32
|
+
const maxRetries = options.timeoutMs === Number.POSITIVE_INFINITY ? undefined : retry.retries;
|
|
33
|
+
const reclaimGuardPath = `${lockPath}.reclaim`;
|
|
34
|
+
let ownsReclaimGuard = false;
|
|
35
|
+
let attempt = 0;
|
|
36
|
+
// Bounded so a genuine denial still surfaces as EPERM, not a lock timeout.
|
|
37
|
+
let transientDenials = 0;
|
|
38
|
+
const withinDenialBudget = () => ++transientDenials <= maxTransientLockDenials;
|
|
39
|
+
const waitForRetry = async () => {
|
|
40
|
+
const elapsed = Date.now() - startedAt;
|
|
41
|
+
if ((options.timeoutMs !== undefined &&
|
|
42
|
+
options.timeoutMs !== Number.POSITIVE_INFINITY &&
|
|
43
|
+
elapsed >= options.timeoutMs) ||
|
|
44
|
+
(maxRetries !== undefined && attempt >= maxRetries)) {
|
|
45
|
+
throw Object.assign(new Error(`file lock timeout for ${normalizedTargetPath}`), {
|
|
46
|
+
code: "file_lock_timeout",
|
|
47
|
+
lockPath,
|
|
48
|
+
normalizedTargetPath,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const remaining = options.timeoutMs === undefined || options.timeoutMs === Number.POSITIVE_INFINITY
|
|
52
|
+
? Number.POSITIVE_INFINITY
|
|
53
|
+
: Math.max(0, options.timeoutMs - elapsed);
|
|
54
|
+
const delay = Math.min(computeSidecarLockDelayMs(retry, attempt), remaining);
|
|
55
|
+
attempt += 1;
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
57
|
+
};
|
|
58
|
+
// Waiting can fail on the caller's own retry or deadline limits. Classifying
|
|
59
|
+
// a denial as contention must not cost them the original diagnosis, so hand
|
|
60
|
+
// the denial back when no further attempt can be scheduled.
|
|
61
|
+
const retryOrRethrowDenial = async (denial) => {
|
|
62
|
+
try {
|
|
63
|
+
await waitForRetry();
|
|
64
|
+
}
|
|
65
|
+
catch (waitError) {
|
|
66
|
+
if (waitError.code === "file_lock_timeout")
|
|
67
|
+
throw denial;
|
|
68
|
+
throw waitError;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
while (true) {
|
|
73
|
+
if (!ownsReclaimGuard && (await sidecarReclaimGuardExists(reclaimGuardPath))) {
|
|
74
|
+
await waitForRetry();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let handle = null;
|
|
78
|
+
let createdSnapshot = null;
|
|
79
|
+
let lockFileCreateDenied = false;
|
|
80
|
+
try {
|
|
81
|
+
const payload = await options.payload();
|
|
82
|
+
const { raw, ownershipToken } = serializeSidecarLockPayload(payload);
|
|
83
|
+
if (options.lockRoot) {
|
|
84
|
+
const relativeLockPath = relativeSidecarLockPath(options.lockRoot, lockPath);
|
|
85
|
+
try {
|
|
86
|
+
await options.lockRoot.create(relativeLockPath, raw, { mkdir: true, mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof FsSafeError && error.code === "already-exists") {
|
|
90
|
+
throw Object.assign(new Error("sidecar lock exists"), { code: "EEXIST" });
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
createdSnapshot = { raw, payload, ownershipToken };
|
|
95
|
+
handle = (await options.lockRoot.open(relativeLockPath)).handle;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
try {
|
|
99
|
+
handle =
|
|
100
|
+
(await createNativeExclusiveFile(lockPath, 0o600)) ??
|
|
101
|
+
(await fs.open(lockPath, "wx"));
|
|
102
|
+
}
|
|
103
|
+
catch (createError) {
|
|
104
|
+
lockFileCreateDenied = isTransientLockFileDenial(createError, lockPath);
|
|
105
|
+
throw createError;
|
|
106
|
+
}
|
|
107
|
+
await handle.writeFile(raw, "utf8");
|
|
108
|
+
}
|
|
109
|
+
const snapshot = { raw, payload, stat: await handle.stat(), ownershipToken };
|
|
110
|
+
const createdHeld = {
|
|
111
|
+
refCount: 1,
|
|
112
|
+
reentrantOwner: options.reentrantOwner,
|
|
113
|
+
handle,
|
|
114
|
+
lockPath,
|
|
115
|
+
snapshot,
|
|
116
|
+
acquiredAt: Date.now(),
|
|
117
|
+
metadata: options.metadata ?? {},
|
|
118
|
+
lockRoot: options.lockRoot,
|
|
119
|
+
parsePayload: options.parsePayload,
|
|
120
|
+
};
|
|
121
|
+
context.held.set(normalizedTargetPath, createdHeld);
|
|
122
|
+
if (ownsReclaimGuard) {
|
|
123
|
+
try {
|
|
124
|
+
await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath);
|
|
125
|
+
ownsReclaimGuard = false;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
await context.releaseHeldLock(normalizedTargetPath, createdHeld, { force: true });
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const returnedHandle = context.handleForHeldLock(normalizedTargetPath, createdHeld);
|
|
133
|
+
const interval = options.compromiseCheckIntervalMs;
|
|
134
|
+
if (options.onCompromised && interval !== undefined && interval > 0) {
|
|
135
|
+
createdHeld.compromiseTimer = setInterval(() => {
|
|
136
|
+
void returnedHandle.verifyStillHeld().then((stillHeld) => {
|
|
137
|
+
if (!stillHeld && createdHeld.compromiseTimer) {
|
|
138
|
+
clearInterval(createdHeld.compromiseTimer);
|
|
139
|
+
createdHeld.compromiseTimer = undefined;
|
|
140
|
+
options.onCompromised?.({ lockPath, normalizedTargetPath });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}, interval);
|
|
144
|
+
createdHeld.compromiseTimer.unref();
|
|
145
|
+
}
|
|
146
|
+
return returnedHandle;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (handle) {
|
|
150
|
+
const failedSnapshot = { payload: null };
|
|
151
|
+
try {
|
|
152
|
+
failedSnapshot.stat = await handle.stat();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Best-effort cleanup of a failed exclusive create.
|
|
156
|
+
}
|
|
157
|
+
const current = context.held.get(normalizedTargetPath);
|
|
158
|
+
if (current?.handle === handle) {
|
|
159
|
+
context.held.delete(normalizedTargetPath);
|
|
160
|
+
}
|
|
161
|
+
await handle.close().catch(() => undefined);
|
|
162
|
+
// The file may be empty or partial JSON, so remove by the identity
|
|
163
|
+
// captured from our exclusive handle rather than by pathname alone.
|
|
164
|
+
await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
|
|
165
|
+
lockRoot: options.lockRoot,
|
|
166
|
+
parsePayload: options.parsePayload,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else if (createdSnapshot) {
|
|
170
|
+
await removeSidecarLockIfUnchanged(lockPath, createdSnapshot, {
|
|
171
|
+
lockRoot: options.lockRoot,
|
|
172
|
+
parsePayload: options.parsePayload,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (lockFileCreateDenied && withinDenialBudget()) {
|
|
176
|
+
await retryOrRethrowDenial(err);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (err.code !== "EEXIST") {
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
if (ownsReclaimGuard) {
|
|
183
|
+
await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath);
|
|
184
|
+
ownsReclaimGuard = false;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const nowMs = Date.now();
|
|
188
|
+
let snapshot;
|
|
189
|
+
try {
|
|
190
|
+
snapshot = await readSidecarLockSnapshot(lockPath, {
|
|
191
|
+
lockRoot: options.lockRoot,
|
|
192
|
+
parsePayload: options.parsePayload,
|
|
193
|
+
rejectNonFile: true,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch (readErr) {
|
|
197
|
+
if (!isTransientLockFileDenial(readErr, lockPath) || !withinDenialBudget())
|
|
198
|
+
throw readErr;
|
|
199
|
+
await retryOrRethrowDenial(readErr);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!snapshot) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (context.held.has(normalizedTargetPath)) {
|
|
206
|
+
await waitForRetry();
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const shouldReclaim = options.shouldReclaim ?? defaultSidecarLockShouldReclaim;
|
|
210
|
+
if (await shouldReclaim({
|
|
211
|
+
lockPath,
|
|
212
|
+
normalizedTargetPath,
|
|
213
|
+
payload: snapshot?.payload ?? null,
|
|
214
|
+
staleMs: options.staleMs,
|
|
215
|
+
nowMs,
|
|
216
|
+
heldByThisProcess: context.held.has(normalizedTargetPath),
|
|
217
|
+
})) {
|
|
218
|
+
if (!(await sidecarLockSnapshotStillPresent(lockPath, snapshot, {
|
|
219
|
+
lockRoot: options.lockRoot,
|
|
220
|
+
parsePayload: options.parsePayload,
|
|
221
|
+
}))) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const staleRecovery = options.staleRecovery ?? "fail-closed";
|
|
225
|
+
if (staleRecovery === "remove-if-unchanged") {
|
|
226
|
+
if (!(await tryAcquireSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath))) {
|
|
227
|
+
await waitForRetry();
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
ownsReclaimGuard = true;
|
|
231
|
+
const removal = await removeStaleSidecarLockIfAllowed({
|
|
232
|
+
lockPath,
|
|
233
|
+
normalizedTargetPath,
|
|
234
|
+
snapshot,
|
|
235
|
+
shouldRemoveStaleLock: options.shouldRemoveStaleLock,
|
|
236
|
+
lockRoot: options.lockRoot,
|
|
237
|
+
parsePayload: options.parsePayload,
|
|
238
|
+
});
|
|
239
|
+
if (removal === "removed" || removal === "changed") {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath);
|
|
243
|
+
ownsReclaimGuard = false;
|
|
244
|
+
}
|
|
245
|
+
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
|
246
|
+
code: "file_lock_stale",
|
|
247
|
+
lockPath,
|
|
248
|
+
normalizedTargetPath,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
await waitForRetry();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
if (ownsReclaimGuard) {
|
|
257
|
+
await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath).catch(() => undefined);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
|
|
2
2
|
export declare function computeSidecarLockDelayMs(retry: SidecarLockRetryOptions, attempt: number): number;
|
|
3
|
+
export declare const maxTransientLockDenials = 8;
|
|
4
|
+
export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
|
|
3
5
|
export declare function sidecarLockPayloadIsStale(payload: unknown, staleMs: number, nowMs: number): boolean;
|
|
6
|
+
export declare function sidecarLockPayloadCreatedAtMs(payload: unknown): number | null;
|
|
4
7
|
export declare function defaultSidecarLockShouldReclaim(params: {
|
|
5
8
|
lockPath: string;
|
|
6
9
|
payload: unknown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock-policy.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEvE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAOjG;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,
|
|
1
|
+
{"version":3,"file":"sidecar-lock-policy.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEvE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAOjG;AASD,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGnF;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAGT;AAED,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU7E;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,OAAO,CAAC,CAQnB"}
|
|
@@ -7,7 +7,23 @@ export function computeSidecarLockDelayMs(retry, attempt) {
|
|
|
7
7
|
const jitter = retry.randomize ? 1 + Math.random() : 1;
|
|
8
8
|
return Math.min(maxTimeout, Math.round(base * jitter));
|
|
9
9
|
}
|
|
10
|
+
// Windows denies access to a lock file while a just-unlinked directory entry
|
|
11
|
+
// is still being torn down, so a contended acquire sees EPERM on a name that is
|
|
12
|
+
// already gone -- both when creating it exclusively and when reading the
|
|
13
|
+
// holder's snapshot. The next attempt succeeds, so this is contention rather
|
|
14
|
+
// than a permission failure. The error must name the lock file itself: the
|
|
15
|
+
// exclusive-create helper opens the parent directory first, and a denial from
|
|
16
|
+
// that setup step carries no teardown evidence and has to reach the caller.
|
|
17
|
+
export const maxTransientLockDenials = 8;
|
|
18
|
+
export function isTransientLockFileDenial(error, lockPath) {
|
|
19
|
+
const denial = error;
|
|
20
|
+
return process.platform === "win32" && denial?.code === "EPERM" && denial.path === lockPath;
|
|
21
|
+
}
|
|
10
22
|
export function sidecarLockPayloadIsStale(payload, staleMs, nowMs) {
|
|
23
|
+
const createdAtMs = sidecarLockPayloadCreatedAtMs(payload);
|
|
24
|
+
return createdAtMs !== null && nowMs - createdAtMs > staleMs;
|
|
25
|
+
}
|
|
26
|
+
export function sidecarLockPayloadCreatedAtMs(payload) {
|
|
11
27
|
const createdAt = payload &&
|
|
12
28
|
typeof payload === "object" &&
|
|
13
29
|
"createdAt" in payload &&
|
|
@@ -15,11 +31,12 @@ export function sidecarLockPayloadIsStale(payload, staleMs, nowMs) {
|
|
|
15
31
|
? payload.createdAt
|
|
16
32
|
: "";
|
|
17
33
|
const createdAtMs = Date.parse(createdAt);
|
|
18
|
-
return Number.isFinite(createdAtMs)
|
|
34
|
+
return Number.isFinite(createdAtMs) ? createdAtMs : null;
|
|
19
35
|
}
|
|
20
36
|
export async function defaultSidecarLockShouldReclaim(params) {
|
|
21
|
-
|
|
22
|
-
|
|
37
|
+
const createdAtMs = sidecarLockPayloadCreatedAtMs(params.payload);
|
|
38
|
+
if (createdAtMs !== null)
|
|
39
|
+
return params.nowMs - createdAtMs > params.staleMs;
|
|
23
40
|
try {
|
|
24
41
|
return params.nowMs - (await fs.stat(params.lockPath)).mtimeMs > params.staleMs;
|
|
25
42
|
}
|
|
@@ -22,8 +22,12 @@ export declare function parseSidecarLockPayload(raw: string, parser?: (raw: stri
|
|
|
22
22
|
export declare function readSidecarLockSnapshot(lockPath: string, options?: {
|
|
23
23
|
lockRoot?: Root;
|
|
24
24
|
parsePayload?: (raw: string) => unknown;
|
|
25
|
+
rejectNonFile?: boolean;
|
|
26
|
+
allowDescriptorIdentityDrift?: boolean;
|
|
25
27
|
}): Promise<SidecarLockSnapshot | null>;
|
|
26
|
-
export declare function readSidecarLockSnapshotSync(lockPath: string, parsePayload?: (raw: string) => unknown
|
|
28
|
+
export declare function readSidecarLockSnapshotSync(lockPath: string, parsePayload?: (raw: string) => unknown, options?: {
|
|
29
|
+
rejectNonFile?: boolean;
|
|
30
|
+
}): SidecarLockSnapshot | null;
|
|
27
31
|
export declare function removeSidecarLockIfUnchangedSync(lockPath: string, observed: SidecarLockSnapshot): boolean;
|
|
28
32
|
export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
|
|
29
33
|
export declare function removeSidecarLockIfUnchanged(lockPath: string, observed: SidecarLockSnapshot | null, options?: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAe,EAAE,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAM7C,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAe,EAAE,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAM7C,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAY3C,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAYF,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7E;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;CACxB,CAMA;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAahF;AAED,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,GAChC,OAAO,CAUT;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IACP,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,4BAA4B,CAAC,EAAE,OAAO,CAAC;CACnC,GACL,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAkErC;AAED,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,MAAM,EAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,EACvC,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACxC,mBAAmB,GAAG,IAAI,CA+B5B;AAED,wBAAgB,gCAAgC,CAC9C,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAKT;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAkBT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,EACpC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,IAAI,CAAC;IAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CAAO,GACzE,OAAO,CAAC,OAAO,CAAC,CAclB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,EACpC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,IAAI,CAAC;IAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CAAO,GACzE,OAAO,CAAC,OAAO,CAAC,CAMlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3F,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CACzC,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CAkClD"}
|
|
@@ -2,9 +2,10 @@ import { randomBytes } 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 { readFileHandleBounded } from "./bounded-read.js";
|
|
5
|
+
import { readFileDescriptorBoundedSync, readFileHandleBounded } from "./bounded-read.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
7
|
import { sameFileIdentity } from "./file-identity.js";
|
|
8
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
8
9
|
const MAX_LOCK_PAYLOAD_BYTES = 1024 * 1024;
|
|
9
10
|
const SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES = 16;
|
|
10
11
|
const SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS = SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES * 8;
|
|
@@ -55,6 +56,7 @@ export function parseSidecarLockPayload(raw, parser) {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
export async function readSidecarLockSnapshot(lockPath, options = {}) {
|
|
59
|
+
let handle;
|
|
58
60
|
try {
|
|
59
61
|
if (options.lockRoot) {
|
|
60
62
|
const opened = await options.lockRoot.open(relativeSidecarLockPath(options.lockRoot, lockPath));
|
|
@@ -70,9 +72,44 @@ export async function readSidecarLockSnapshot(lockPath, options = {}) {
|
|
|
70
72
|
await opened.handle.close().catch(() => undefined);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
const before = await fs.lstat(lockPath);
|
|
76
|
+
if (!before.isFile() || before.isSymbolicLink()) {
|
|
77
|
+
if (options.rejectNonFile) {
|
|
78
|
+
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
await getFsSafeTestHooks()?.beforeSidecarLockSnapshotOpen?.(lockPath);
|
|
83
|
+
const noFollow = process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
|
|
84
|
+
? fsSync.constants.O_NOFOLLOW
|
|
85
|
+
: 0;
|
|
86
|
+
try {
|
|
87
|
+
handle = await fs.open(lockPath, fsSync.constants.O_RDONLY |
|
|
88
|
+
noFollow |
|
|
89
|
+
(typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (options.rejectNonFile && error.code === "ELOOP") {
|
|
93
|
+
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`, {
|
|
94
|
+
cause: error,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const opened = await handle.stat();
|
|
100
|
+
if (!opened.isFile()) {
|
|
101
|
+
if (options.rejectNonFile) {
|
|
102
|
+
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!options.allowDescriptorIdentityDrift && !sameFileIdentity(before, opened))
|
|
107
|
+
return null;
|
|
108
|
+
const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
|
|
109
|
+
const after = await fs.lstat(lockPath);
|
|
110
|
+
if (!after.isFile() || !sameFileIdentity(before, after))
|
|
111
|
+
return null;
|
|
112
|
+
return { raw, payload: parseSidecarLockPayload(raw, options.parsePayload), stat: after };
|
|
76
113
|
}
|
|
77
114
|
catch (err) {
|
|
78
115
|
if (err.code === "ENOENT" ||
|
|
@@ -81,19 +118,26 @@ export async function readSidecarLockSnapshot(lockPath, options = {}) {
|
|
|
81
118
|
}
|
|
82
119
|
throw err;
|
|
83
120
|
}
|
|
121
|
+
finally {
|
|
122
|
+
await handle?.close().catch(() => undefined);
|
|
123
|
+
}
|
|
84
124
|
}
|
|
85
|
-
export function readSidecarLockSnapshotSync(lockPath, parsePayload) {
|
|
125
|
+
export function readSidecarLockSnapshotSync(lockPath, parsePayload, options = {}) {
|
|
86
126
|
let fd;
|
|
87
127
|
try {
|
|
88
128
|
const before = fsSync.lstatSync(lockPath);
|
|
89
|
-
if (!before.isFile() || before.isSymbolicLink())
|
|
129
|
+
if (!before.isFile() || before.isSymbolicLink()) {
|
|
130
|
+
if (options.rejectNonFile) {
|
|
131
|
+
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
|
132
|
+
}
|
|
90
133
|
return null;
|
|
134
|
+
}
|
|
91
135
|
const noFollow = process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
|
|
92
136
|
? fsSync.constants.O_NOFOLLOW
|
|
93
137
|
: 0;
|
|
94
138
|
fd = fsSync.openSync(lockPath, fsSync.constants.O_RDONLY | noFollow);
|
|
95
139
|
const opened = fsSync.fstatSync(fd);
|
|
96
|
-
const raw =
|
|
140
|
+
const raw = readFileDescriptorBoundedSync(fd, MAX_LOCK_PAYLOAD_BYTES).toString("utf8");
|
|
97
141
|
const after = fsSync.lstatSync(lockPath);
|
|
98
142
|
if (!sameFileIdentity(before, opened) || !sameFileIdentity(opened, after))
|
|
99
143
|
return null;
|
|
@@ -139,7 +183,10 @@ export function sidecarLockSnapshotMatches(current, observed) {
|
|
|
139
183
|
return observed.stat !== undefined && current.stat !== undefined;
|
|
140
184
|
}
|
|
141
185
|
export async function removeSidecarLockIfUnchanged(lockPath, observed, options = {}) {
|
|
142
|
-
const current = await readSidecarLockSnapshot(lockPath,
|
|
186
|
+
const current = await readSidecarLockSnapshot(lockPath, {
|
|
187
|
+
...options,
|
|
188
|
+
allowDescriptorIdentityDrift: observed?.ownershipToken !== undefined,
|
|
189
|
+
});
|
|
143
190
|
if (!current || !observed || !sidecarLockSnapshotMatches(current, observed)) {
|
|
144
191
|
return false;
|
|
145
192
|
}
|
|
@@ -152,7 +199,10 @@ export async function removeSidecarLockIfUnchanged(lockPath, observed, options =
|
|
|
152
199
|
return true;
|
|
153
200
|
}
|
|
154
201
|
export async function sidecarLockSnapshotStillPresent(lockPath, observed, options = {}) {
|
|
155
|
-
const current = await readSidecarLockSnapshot(lockPath,
|
|
202
|
+
const current = await readSidecarLockSnapshot(lockPath, {
|
|
203
|
+
...options,
|
|
204
|
+
allowDescriptorIdentityDrift: observed?.ownershipToken !== undefined,
|
|
205
|
+
});
|
|
156
206
|
return !!current && !!observed && sidecarLockSnapshotMatches(current, observed);
|
|
157
207
|
}
|
|
158
208
|
export async function sidecarReclaimGuardExists(pathname) {
|
|
@@ -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":"AASA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAmLjC,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAS3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAYL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBASW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
|