@openclaw/fs-safe 0.5.6 → 0.7.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 +89 -0
- package/README.md +23 -10
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/archive-crc32.d.ts +2 -0
- package/dist/archive-crc32.d.ts.map +1 -0
- package/dist/archive-crc32.js +14 -0
- package/dist/archive-deadline.d.ts +3 -0
- package/dist/archive-deadline.d.ts.map +1 -1
- package/dist/archive-deadline.js +44 -8
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +1 -0
- package/dist/archive-errors.d.ts +1 -0
- package/dist/archive-errors.d.ts.map +1 -1
- package/dist/archive-errors.js +3 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +26 -16
- package/dist/archive-kind.js +2 -2
- package/dist/archive-limits.d.ts +11 -3
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +24 -0
- package/dist/archive-native.d.ts +3 -2
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +31 -9
- package/dist/archive-policy.d.ts +2 -0
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +9 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +88 -41
- package/dist/archive-staging.d.ts +3 -0
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +91 -43
- package/dist/archive-tar-admission.d.ts +7 -0
- package/dist/archive-tar-admission.d.ts.map +1 -0
- package/dist/archive-tar-admission.js +43 -0
- package/dist/archive-tar-gnu.d.ts +2 -0
- package/dist/archive-tar-gnu.d.ts.map +1 -0
- package/dist/archive-tar-gnu.js +20 -0
- package/dist/archive-tar-header.d.ts +8 -0
- package/dist/archive-tar-header.d.ts.map +1 -0
- package/dist/archive-tar-header.js +47 -0
- package/dist/archive-tar-meta.d.ts +29 -1
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +135 -19
- package/dist/archive-tar-pax.d.ts +8 -0
- package/dist/archive-tar-pax.d.ts.map +1 -0
- package/dist/archive-tar-pax.js +100 -0
- package/dist/archive-tar-runtime.d.ts +4 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-tar-runtime.js +3 -0
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +8 -2
- package/dist/archive-zip-admission.d.ts +7 -0
- package/dist/archive-zip-admission.d.ts.map +1 -0
- package/dist/archive-zip-admission.js +60 -0
- package/dist/archive-zip-count.d.ts +2 -0
- package/dist/archive-zip-count.d.ts.map +1 -0
- package/dist/archive-zip-count.js +140 -0
- package/dist/archive-zip-directory.d.ts +8 -0
- package/dist/archive-zip-directory.d.ts.map +1 -0
- package/dist/archive-zip-directory.js +224 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -1
- package/dist/archive-zip-integrity.js +1 -14
- package/dist/archive-zip-names.d.ts +12 -0
- package/dist/archive-zip-names.d.ts.map +1 -0
- package/dist/archive-zip-names.js +99 -0
- package/dist/archive-zip-preflight.d.ts +1 -1
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +8 -145
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +63 -42
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +2 -4
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +3 -10
- package/dist/byte-budget.d.ts +5 -0
- package/dist/byte-budget.d.ts.map +1 -0
- package/dist/byte-budget.js +9 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +25 -19
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +26 -9
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +9 -5
- package/dist/file-store-limit.d.ts +2 -0
- package/dist/file-store-limit.d.ts.map +1 -0
- package/dist/file-store-limit.js +8 -0
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +38 -8
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +26 -26
- package/dist/json-durable-queue-directory.d.ts +2 -0
- package/dist/json-durable-queue-directory.d.ts.map +1 -0
- package/dist/json-durable-queue-directory.js +20 -0
- package/dist/json-durable-queue-ownership.d.ts +14 -0
- package/dist/json-durable-queue-ownership.d.ts.map +1 -0
- package/dist/json-durable-queue-ownership.js +168 -0
- package/dist/json-durable-queue-retirement.d.ts +9 -0
- package/dist/json-durable-queue-retirement.d.ts.map +1 -0
- package/dist/json-durable-queue-retirement.js +126 -0
- package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
- package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
- package/dist/json-durable-queue-transfer-lock.js +19 -0
- package/dist/json-durable-queue.d.ts +1 -0
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +90 -57
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +27 -8
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +4 -2
- package/dist/native-binding.d.ts +15 -3
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts +4 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +22 -6
- package/dist/native-pinned-write-windows.d.ts +8 -0
- package/dist/native-pinned-write-windows.d.ts.map +1 -0
- package/dist/native-pinned-write-windows.js +92 -0
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +136 -127
- package/dist/native-staged-file.d.ts +24 -0
- package/dist/native-staged-file.d.ts.map +1 -0
- package/dist/native-staged-file.js +337 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -4
- package/dist/opened-realpath.d.ts +2 -0
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +12 -7
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +11 -110
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +4 -2
- package/dist/owner-dacl.d.ts.map +1 -1
- package/dist/owner-dacl.js +2 -1
- package/dist/permission-exec.d.ts +19 -0
- package/dist/permission-exec.d.ts.map +1 -1
- package/dist/permission-exec.js +57 -11
- package/dist/permissions-public.d.ts +1 -1
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-windows.d.ts +3 -0
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +16 -5
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +27 -48
- package/dist/pinned-write.d.ts +6 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +26 -37
- package/dist/private-directory.d.ts.map +1 -1
- package/dist/private-directory.js +3 -2
- package/dist/private-temp-workspace.d.ts +3 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +81 -56
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +2 -4
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +6 -4
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +136 -82
- package/dist/replace-file-copy-fallback.d.ts +3 -1
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +21 -36
- package/dist/replace-file-copy-source.d.ts +21 -0
- package/dist/replace-file-copy-source.d.ts.map +1 -0
- package/dist/replace-file-copy-source.js +112 -0
- package/dist/replace-file-descriptor.d.ts +13 -3
- package/dist/replace-file-descriptor.d.ts.map +1 -1
- package/dist/replace-file-descriptor.js +32 -7
- package/dist/replace-file-rename-policy.d.ts +7 -0
- package/dist/replace-file-rename-policy.d.ts.map +1 -0
- package/dist/replace-file-rename-policy.js +30 -0
- package/dist/replace-file-temp-owner.d.ts +46 -0
- package/dist/replace-file-temp-owner.d.ts.map +1 -0
- package/dist/replace-file-temp-owner.js +346 -0
- package/dist/replace-file.d.ts +6 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +72 -58
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +118 -98
- package/dist/root-paths.d.ts +11 -14
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +36 -27
- package/dist/root-write-verification.d.ts +11 -0
- package/dist/root-write-verification.d.ts.map +1 -0
- package/dist/root-write-verification.js +91 -0
- package/dist/secret-file.d.ts +1 -6
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +49 -120
- package/dist/secret-read-async.d.ts +1 -1
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +51 -72
- package/dist/secret-read-policy.d.ts +13 -0
- package/dist/secret-read-policy.d.ts.map +1 -0
- package/dist/secret-read-policy.js +28 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +42 -29
- package/dist/sibling-staged-file.d.ts +15 -0
- package/dist/sibling-staged-file.d.ts.map +1 -0
- package/dist/sibling-staged-file.js +148 -0
- package/dist/sibling-temp.d.ts +3 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +30 -74
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +48 -27
- package/dist/sidecar-lock-handle.d.ts +6 -2
- package/dist/sidecar-lock-handle.d.ts.map +1 -1
- package/dist/sidecar-lock-handle.js +17 -3
- package/dist/sidecar-lock-policy.d.ts +2 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +29 -7
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +28 -16
- package/dist/staged-directory.d.ts +16 -0
- package/dist/staged-directory.d.ts.map +1 -0
- package/dist/staged-directory.js +60 -0
- package/dist/staged-file-types.d.ts +56 -0
- package/dist/staged-file-types.d.ts.map +1 -0
- package/dist/staged-file-types.js +1 -0
- package/dist/staged-file.d.ts +10 -0
- package/dist/staged-file.d.ts.map +1 -0
- package/dist/staged-file.js +15 -0
- package/dist/strict-file-identity.d.ts +6 -0
- package/dist/strict-file-identity.d.ts.map +1 -0
- package/dist/strict-file-identity.js +48 -0
- package/dist/suppressed-error.d.ts +6 -0
- package/dist/suppressed-error.d.ts.map +1 -0
- package/dist/suppressed-error.js +15 -0
- package/dist/temp-cleanup.d.ts +2 -0
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +25 -10
- package/dist/temp-workspace-owner.d.ts +23 -0
- package/dist/temp-workspace-owner.d.ts.map +1 -0
- package/dist/temp-workspace-owner.js +320 -0
- package/dist/temp.d.ts +1 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +5 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-owner.d.ts +3 -0
- package/dist/windows-owner.d.ts.map +1 -1
- package/dist/windows-owner.js +10 -2
- package/docs/advanced.md +19 -2
- package/docs/archive.md +250 -35
- package/docs/atomic.md +11 -2
- package/docs/config.md +7 -0
- package/docs/contributing.md +45 -7
- package/docs/durability.md +18 -5
- package/docs/errors.md +16 -1
- package/docs/file-store.md +2 -0
- package/docs/index.md +3 -1
- package/docs/install.md +20 -8
- package/docs/json.md +8 -4
- package/docs/migrating-to-0.5.md +7 -7
- package/docs/migrating-to-0.6.md +43 -0
- package/docs/native-helper.md +30 -8
- package/docs/native.md +73 -17
- package/docs/output.md +10 -0
- package/docs/path-scope.md +28 -2
- package/docs/permissions.md +13 -2
- package/docs/public-api.md +6 -3
- package/docs/quickstart.md +1 -1
- package/docs/reading.md +1 -1
- package/docs/regular-file.md +9 -2
- package/docs/root.md +3 -1
- package/docs/secret-file.md +12 -0
- package/docs/secure-file.md +21 -3
- package/docs/security-model.md +33 -1
- package/docs/sidecar-lock.md +15 -1
- package/docs/staged-file.md +178 -0
- package/docs/store.md +13 -0
- package/docs/temp.md +128 -14
- package/docs/testing.md +1 -1
- package/docs/writing.md +19 -0
- package/package.json +16 -9
- 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/secure-file.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { constants as fsConstants } from "node:fs";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { readFileHandleBounded } from "./bounded-read.js";
|
|
4
|
+
import { normalizeMaxBytes } from "./byte-budget.js";
|
|
5
5
|
import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
-
import { sameFileIdentity } from "./file-identity.js";
|
|
8
7
|
import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
|
|
9
8
|
import { isPathInside, isSymlinkOpenError } from "./path.js";
|
|
9
|
+
import { formatPermissionErrorDetail } from "./permission-exec.js";
|
|
10
|
+
import { resolveReadOpenFlags } from "./read-open-flags.js";
|
|
10
11
|
import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, } from "./permissions.js";
|
|
11
|
-
|
|
12
|
-
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
12
|
+
import { inspectFileIdentity } from "./strict-file-identity.js";
|
|
13
13
|
function isAbsolutePathname(value) {
|
|
14
14
|
return (path.isAbsolute(value) ||
|
|
15
15
|
(process.platform === "win32" &&
|
|
@@ -18,7 +18,7 @@ function isAbsolutePathname(value) {
|
|
|
18
18
|
function label(options) {
|
|
19
19
|
return options.label ?? "Secure file";
|
|
20
20
|
}
|
|
21
|
-
async function openSecureHandle(options) {
|
|
21
|
+
async function openSecureHandle(options, maxBytes) {
|
|
22
22
|
assertNoUnsafeDeviceReadPath(options.filePath);
|
|
23
23
|
if (isWindowsNetworkPath(options.filePath, "win32") && !options.trust?.allowNetworkPath) {
|
|
24
24
|
throw new FsSafeError("invalid-path", `${label(options)} must be a local absolute path.`);
|
|
@@ -31,15 +31,19 @@ async function openSecureHandle(options) {
|
|
|
31
31
|
cause: err,
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
|
-
if (preStat.
|
|
35
|
-
|
|
34
|
+
if (preStat.isSymbolicLink()) {
|
|
35
|
+
if (!options.trust?.allowSymlink) {
|
|
36
|
+
throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
|
|
37
|
+
}
|
|
36
38
|
}
|
|
37
|
-
if (preStat.
|
|
38
|
-
throw new FsSafeError("
|
|
39
|
+
else if (!preStat.isFile()) {
|
|
40
|
+
throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
|
|
39
41
|
}
|
|
40
42
|
let handle;
|
|
41
43
|
try {
|
|
42
|
-
handle = await fs.open(options.filePath,
|
|
44
|
+
handle = await fs.open(options.filePath, resolveReadOpenFlags({
|
|
45
|
+
followSymlinks: options.trust?.allowSymlink === true,
|
|
46
|
+
}));
|
|
43
47
|
}
|
|
44
48
|
catch (err) {
|
|
45
49
|
if (isSymlinkOpenError(err)) {
|
|
@@ -52,22 +56,20 @@ async function openSecureHandle(options) {
|
|
|
52
56
|
if (!openedStat.isFile()) {
|
|
53
57
|
throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
|
|
54
58
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
const openedIdentity = await inspectFileIdentity(() => handle.stat({ bigint: true }));
|
|
60
|
+
await inspectFileIdentity(async () => {
|
|
61
|
+
const pathStat = options.trust?.allowSymlink
|
|
62
|
+
? await fs.stat(options.filePath, { bigint: true })
|
|
63
|
+
: await fs.lstat(options.filePath, { bigint: true });
|
|
64
|
+
if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
|
|
65
|
+
throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
|
|
66
|
+
}
|
|
67
|
+
return pathStat;
|
|
68
|
+
}, openedIdentity);
|
|
64
69
|
const realPath = await fs.realpath(options.filePath);
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
throw new FsSafeError("
|
|
68
|
-
}
|
|
69
|
-
if (options.io?.maxBytes !== undefined && openedStat.size > options.io.maxBytes) {
|
|
70
|
-
throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
|
|
70
|
+
await inspectFileIdentity(() => fs.stat(realPath, { bigint: true }), openedIdentity);
|
|
71
|
+
if (maxBytes !== undefined && openedStat.size > maxBytes) {
|
|
72
|
+
throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${maxBytes}).`);
|
|
71
73
|
}
|
|
72
74
|
return { handle, pathStat: openedStat, realPath };
|
|
73
75
|
}
|
|
@@ -111,11 +113,21 @@ async function assertSecurePermissions(options, stat, realPath) {
|
|
|
111
113
|
const permissions = platform === "win32"
|
|
112
114
|
? await inspectPathPermissions(realPath, options.inject)
|
|
113
115
|
: inspectOpenedPermissions(stat, platform);
|
|
116
|
+
const reason = permissions.error ? `: ${formatPermissionErrorDetail(permissions.error)}` : "";
|
|
117
|
+
const diagnostics = {
|
|
118
|
+
...(permissions.errorCause !== undefined ? { cause: permissions.errorCause } : {}),
|
|
119
|
+
...(permissions.ownerError || permissions.errorDetail ? {
|
|
120
|
+
details: {
|
|
121
|
+
...(permissions.ownerError ? { ownerError: formatPermissionErrorDetail(permissions.ownerError) } : {}),
|
|
122
|
+
...permissions.errorDetail,
|
|
123
|
+
},
|
|
124
|
+
} : {}),
|
|
125
|
+
};
|
|
114
126
|
if (!permissions.ok) {
|
|
115
|
-
throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}
|
|
127
|
+
throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}${reason}`, diagnostics);
|
|
116
128
|
}
|
|
117
129
|
if (platform === "win32" && permissions.source === "unknown") {
|
|
118
|
-
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}
|
|
130
|
+
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}${reason || "."}`, diagnostics);
|
|
119
131
|
}
|
|
120
132
|
if (platform === "win32" && permissions.ownerTrusted !== true) {
|
|
121
133
|
throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
|
|
@@ -156,11 +168,12 @@ async function readHandleWithTimeout(handle, timeoutMs, maxBytes) {
|
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
170
|
export async function readSecureFile(options) {
|
|
159
|
-
const
|
|
171
|
+
const maxBytes = normalizeMaxBytes(options.io?.maxBytes);
|
|
172
|
+
const opened = await openSecureHandle(options, maxBytes);
|
|
160
173
|
try {
|
|
161
174
|
await assertTrustedDirs(options, opened.realPath);
|
|
162
175
|
const permissions = await assertSecurePermissions(options, opened.pathStat, opened.realPath);
|
|
163
|
-
const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs,
|
|
176
|
+
const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs, maxBytes);
|
|
164
177
|
return { buffer, realPath: opened.realPath, stat: opened.pathStat, permissions };
|
|
165
178
|
}
|
|
166
179
|
finally {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function writeCallbackSibling<T>(params: {
|
|
2
|
+
tempPath: string;
|
|
3
|
+
write: (tempPath: string) => Promise<T>;
|
|
4
|
+
resolveFinalPath: (result: T) => string;
|
|
5
|
+
mode?: number;
|
|
6
|
+
/** Preserve the caller's historical best-effort mode behavior. */
|
|
7
|
+
ignoreModeError?: boolean;
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
syncTempFile: boolean;
|
|
10
|
+
syncParentDir: boolean;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
filePath: string;
|
|
13
|
+
result: T;
|
|
14
|
+
}>;
|
|
15
|
+
//# sourceMappingURL=sibling-staged-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sibling-staged-file.d.ts","sourceRoot":"","sources":["../src/sibling-staged-file.ts"],"names":[],"mappings":"AAkCA,wBAAsB,oBAAoB,CAAC,CAAC,EAAE,MAAM,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;CACxB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC,CA6G3C"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fsSync, {} from "node:fs";
|
|
2
|
+
import fs, {} from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
5
|
+
import { syncDirectoryBestEffort } from "./directory-durability.js";
|
|
6
|
+
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { resolveReadOpenFlags } from "./read-open-flags.js";
|
|
8
|
+
import { inspectFileIdentity } from "./strict-file-identity.js";
|
|
9
|
+
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
10
|
+
import { serializePathWrite } from "./write-queue.js";
|
|
11
|
+
function assertRegularFile(stat) {
|
|
12
|
+
if (stat.isSymbolicLink()) {
|
|
13
|
+
throw new FsSafeError("symlink", "symlink sibling temp not allowed");
|
|
14
|
+
}
|
|
15
|
+
if (!stat.isFile()) {
|
|
16
|
+
throw new FsSafeError("not-file", "sibling temp must be a regular file");
|
|
17
|
+
}
|
|
18
|
+
if (stat.nlink !== 1n) {
|
|
19
|
+
throw new FsSafeError("hardlink", "sibling temp must have exactly one link");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function inspectStage(inspect, expected) {
|
|
23
|
+
return await inspectFileIdentity(async () => {
|
|
24
|
+
const stat = await inspect();
|
|
25
|
+
assertRegularFile(stat);
|
|
26
|
+
return stat;
|
|
27
|
+
}, expected);
|
|
28
|
+
}
|
|
29
|
+
// Callback paths are not owned until all three admission observations agree.
|
|
30
|
+
// Keep one descriptor and one exact identity through mode, sync, rename and cleanup.
|
|
31
|
+
// Read/write access is needed only when the caller requests file synchronization.
|
|
32
|
+
export async function writeCallbackSibling(params) {
|
|
33
|
+
const parent = path.dirname(params.tempPath);
|
|
34
|
+
const guard = await createAsyncDirectoryGuard(parent);
|
|
35
|
+
const parentIdentity = await inspectFileIdentity(() => fs.lstat(parent, { bigint: true }));
|
|
36
|
+
const assertParent = async () => {
|
|
37
|
+
await assertAsyncDirectoryGuard(guard);
|
|
38
|
+
await inspectFileIdentity(() => fs.lstat(parent, { bigint: true }), parentIdentity);
|
|
39
|
+
};
|
|
40
|
+
let handle;
|
|
41
|
+
let identity;
|
|
42
|
+
let unregister;
|
|
43
|
+
let renamed = false;
|
|
44
|
+
let failure;
|
|
45
|
+
const inspectPath = (pathname, expected) => inspectStage(() => fs.lstat(pathname, { bigint: true }), expected);
|
|
46
|
+
const assertCurrent = async (pathname) => {
|
|
47
|
+
await assertParent();
|
|
48
|
+
const opened = await inspectStage(() => handle.stat({ bigint: true }), identity);
|
|
49
|
+
const current = await inspectPath(pathname, opened);
|
|
50
|
+
if (params.maxBytes !== undefined &&
|
|
51
|
+
(opened.size > params.maxBytes || current.size > params.maxBytes)) {
|
|
52
|
+
throw new FsSafeError("too-large", `sibling temp exceeds maxBytes (${params.maxBytes})`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const result = await params.write(params.tempPath);
|
|
57
|
+
await assertParent();
|
|
58
|
+
const before = await inspectPath(params.tempPath);
|
|
59
|
+
try {
|
|
60
|
+
// No create/truncate flags; O_NONBLOCK also bounds a FIFO swap during open.
|
|
61
|
+
const access = params.syncTempFile ? fsSync.constants.O_RDWR : fsSync.constants.O_RDONLY;
|
|
62
|
+
handle = await fs.open(params.tempPath, access | resolveReadOpenFlags());
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error?.code === "ELOOP") {
|
|
66
|
+
throw new FsSafeError("symlink", "symlink sibling temp not allowed", { cause: error });
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const opened = await inspectStage(() => handle.stat({ bigint: true }), before);
|
|
71
|
+
await inspectPath(params.tempPath, opened);
|
|
72
|
+
await assertParent();
|
|
73
|
+
identity = opened;
|
|
74
|
+
unregister = registerTempPathForExit(params.tempPath, { identity, singleLinkFile: true });
|
|
75
|
+
const filePath = path.resolve(params.resolveFinalPath(result));
|
|
76
|
+
if (path.dirname(filePath) !== parent) {
|
|
77
|
+
throw new Error("Final path must be in the sibling temp directory.");
|
|
78
|
+
}
|
|
79
|
+
if (filePath === params.tempPath) {
|
|
80
|
+
throw new FsSafeError("invalid-path", "final path must differ from the sibling temp path");
|
|
81
|
+
}
|
|
82
|
+
await serializePathWrite(filePath, async () => {
|
|
83
|
+
await assertCurrent(params.tempPath);
|
|
84
|
+
if (params.mode !== undefined) {
|
|
85
|
+
try {
|
|
86
|
+
await handle.chmod(params.mode);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!params.ignoreModeError)
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (params.syncTempFile) {
|
|
94
|
+
await assertCurrent(params.tempPath);
|
|
95
|
+
try {
|
|
96
|
+
await handle.sync();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error?.code !== "EPERM")
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await assertCurrent(params.tempPath);
|
|
104
|
+
await fs.rename(params.tempPath, filePath);
|
|
105
|
+
// A later verification failure never authorizes rollback of the final name.
|
|
106
|
+
renamed = true;
|
|
107
|
+
unregister();
|
|
108
|
+
await assertCurrent(filePath);
|
|
109
|
+
if (params.syncParentDir)
|
|
110
|
+
await syncDirectoryBestEffort(parent);
|
|
111
|
+
await assertCurrent(filePath);
|
|
112
|
+
});
|
|
113
|
+
return { filePath, result };
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
failure = { error };
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
try {
|
|
121
|
+
if (!renamed && identity) {
|
|
122
|
+
try {
|
|
123
|
+
await assertParent();
|
|
124
|
+
await inspectStage(() => handle.stat({ bigint: true }), identity);
|
|
125
|
+
await inspectPath(params.tempPath, identity);
|
|
126
|
+
await fs.unlink(params.tempPath);
|
|
127
|
+
unregister?.();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// Preserve observed substitutes; retry only operational cleanup failures.
|
|
131
|
+
if (error instanceof FsSafeError || error?.code === "ENOENT") {
|
|
132
|
+
unregister?.();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
try {
|
|
139
|
+
await handle?.close();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (failure)
|
|
143
|
+
throw new AggregateError([failure.error, error], "sibling publication and close failed");
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/sibling-temp.d.ts
CHANGED
|
@@ -5,8 +5,11 @@ export type WriteSiblingTempFileOptions<T> = {
|
|
|
5
5
|
tempPrefix?: string;
|
|
6
6
|
dirMode?: number;
|
|
7
7
|
chmodDir?: boolean;
|
|
8
|
+
/** Final file mode; omitted preserves the producer's mode. Applied through the retained descriptor. */
|
|
8
9
|
mode?: number;
|
|
10
|
+
/** Sync the staged descriptor before rename; defaults to false. */
|
|
9
11
|
syncTempFile?: boolean;
|
|
12
|
+
/** Best-effort parent directory sync after rename; defaults to false. */
|
|
10
13
|
syncParentDir?: boolean;
|
|
11
14
|
};
|
|
12
15
|
export type WriteSiblingTempFileResult<T> = {
|
|
@@ -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,uGAAuG;IACvG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,yEAAyE;IACzE,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;AASF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAoBxC;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,CA0ChB"}
|
package/dist/sibling-temp.js
CHANGED
|
@@ -2,85 +2,40 @@ import crypto, { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
5
|
-
import { syncDirectoryBestEffort } from "./directory-durability.js";
|
|
6
|
-
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
7
5
|
import { sanitizeUntrustedFileName } from "./filename.js";
|
|
6
|
+
import { applyDirectoryMode } from "./replace-file-descriptor.js";
|
|
8
7
|
import { root } from "./root.js";
|
|
9
8
|
import { assertSafePathPrefix } from "./safe-path-segment.js";
|
|
10
9
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
11
|
-
import {
|
|
10
|
+
import { writeCallbackSibling } from "./sibling-staged-file.js";
|
|
11
|
+
import { tempFile } from "./temp-target.js";
|
|
12
12
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
13
|
-
import { serializePathWrite } from "./write-queue.js";
|
|
14
13
|
function buildTempPath(dir, tempPrefix) {
|
|
15
14
|
const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-stream", {
|
|
16
15
|
label: "sibling temp prefix",
|
|
17
16
|
});
|
|
18
17
|
return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
|
|
19
18
|
}
|
|
20
|
-
async function syncFileBestEffort(filePath) {
|
|
21
|
-
const handle = await fs.open(filePath, "r+");
|
|
22
|
-
try {
|
|
23
|
-
await handle.sync();
|
|
24
|
-
}
|
|
25
|
-
catch (error) {
|
|
26
|
-
if (error.code !== "EPERM") {
|
|
27
|
-
throw error;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
finally {
|
|
31
|
-
await handle.close();
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
function assertFinalPathIsSibling(dir, filePath) {
|
|
35
|
-
const resolvedDir = path.resolve(dir);
|
|
36
|
-
const resolvedFile = path.resolve(filePath);
|
|
37
|
-
if (path.dirname(resolvedFile) !== resolvedDir) {
|
|
38
|
-
throw new Error("Final path must be in the sibling temp directory.");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
19
|
export async function writeSiblingTempFile(options) {
|
|
42
20
|
const dir = path.resolve(options.dir);
|
|
43
21
|
await fs.mkdir(dir, { recursive: true, mode: options.dirMode ?? 0o700 });
|
|
44
22
|
if (options.chmodDir !== false) {
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
let tempExists = false;
|
|
51
|
-
try {
|
|
52
|
-
tempExists = true;
|
|
53
|
-
const result = await options.writeTemp(tempPath);
|
|
54
|
-
unregisterTempPath.setIdentity(await fs.lstat(tempPath, { bigint: true }));
|
|
55
|
-
if (options.mode !== undefined) {
|
|
56
|
-
await fs.chmod(tempPath, options.mode).catch(() => undefined);
|
|
57
|
-
}
|
|
58
|
-
if (options.syncTempFile) {
|
|
59
|
-
await syncFileBestEffort(tempPath);
|
|
60
|
-
}
|
|
61
|
-
const filePath = path.resolve(options.resolveFinalPath(result));
|
|
62
|
-
assertFinalPathIsSibling(dir, filePath);
|
|
63
|
-
await serializePathWrite(filePath, async () => {
|
|
64
|
-
await withAsyncDirectoryGuards([dirGuard], async () => {
|
|
65
|
-
await fs.rename(tempPath, filePath);
|
|
66
|
-
});
|
|
67
|
-
tempExists = false;
|
|
68
|
-
unregisterTempPath();
|
|
69
|
-
if (options.mode !== undefined) {
|
|
70
|
-
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
71
|
-
}
|
|
72
|
-
if (options.syncParentDir) {
|
|
73
|
-
await syncDirectoryBestEffort(dir);
|
|
74
|
-
}
|
|
23
|
+
await applyDirectoryMode({
|
|
24
|
+
fsModule: fs,
|
|
25
|
+
dirPath: dir,
|
|
26
|
+
mode: options.dirMode ?? 0o700,
|
|
27
|
+
ignoreChmodError: true,
|
|
75
28
|
});
|
|
76
|
-
return { filePath, result };
|
|
77
|
-
}
|
|
78
|
-
finally {
|
|
79
|
-
if (tempExists) {
|
|
80
|
-
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
81
|
-
}
|
|
82
|
-
unregisterTempPath();
|
|
83
29
|
}
|
|
30
|
+
return await writeCallbackSibling({
|
|
31
|
+
tempPath: buildTempPath(dir, options.tempPrefix),
|
|
32
|
+
write: options.writeTemp,
|
|
33
|
+
resolveFinalPath: options.resolveFinalPath,
|
|
34
|
+
mode: options.mode,
|
|
35
|
+
ignoreModeError: true,
|
|
36
|
+
syncTempFile: options.syncTempFile === true,
|
|
37
|
+
syncParentDir: options.syncParentDir === true,
|
|
38
|
+
});
|
|
84
39
|
}
|
|
85
40
|
function buildSiblingTempPath(params) {
|
|
86
41
|
const id = crypto.randomUUID();
|
|
@@ -107,18 +62,20 @@ export async function writeViaSiblingTempPath(params) {
|
|
|
107
62
|
throw new Error("Target path is outside the allowed root");
|
|
108
63
|
}
|
|
109
64
|
const rootGuard = await createAsyncDirectoryGuard(rootDir);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
fallbackFileName: params.fallbackFileName ?? "output.bin",
|
|
118
|
-
tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
|
|
65
|
+
const workspace = await tempFile({
|
|
66
|
+
rootDir: resolveSecureTempRoot({
|
|
67
|
+
fallbackPrefix: "fs-safe-output",
|
|
68
|
+
unsafeFallbackLabel: "sibling temp output dir",
|
|
69
|
+
warn: () => undefined,
|
|
70
|
+
}),
|
|
71
|
+
prefix: "fs-safe-output",
|
|
119
72
|
});
|
|
120
|
-
const unregisterTempPath = registerTempPathForExit(tempDir, { recursive: true });
|
|
121
73
|
try {
|
|
74
|
+
const tempPath = buildSiblingTempPath({
|
|
75
|
+
targetPath: path.join(workspace.dir, path.basename(targetPath)),
|
|
76
|
+
fallbackFileName: params.fallbackFileName ?? "output.bin",
|
|
77
|
+
tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
|
|
78
|
+
});
|
|
122
79
|
await getFsSafeTestHooks()?.beforeSiblingTempWrite?.(tempPath);
|
|
123
80
|
await params.writeTemp(tempPath);
|
|
124
81
|
await assertAsyncDirectoryGuard(rootGuard);
|
|
@@ -127,7 +84,6 @@ export async function writeViaSiblingTempPath(params) {
|
|
|
127
84
|
await assertAsyncDirectoryGuard(rootGuard);
|
|
128
85
|
}
|
|
129
86
|
finally {
|
|
130
|
-
await
|
|
131
|
-
unregisterTempPath();
|
|
87
|
+
await workspace.cleanup();
|
|
132
88
|
}
|
|
133
89
|
}
|
|
@@ -1 +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;
|
|
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;AAS3C,OAAO,EAUL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAG5F,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,CA+Q5B"}
|
|
@@ -2,8 +2,9 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { createNativeExclusiveFile } from "./native-operations.js";
|
|
5
|
-
import { computeSidecarLockDelayMs, defaultSidecarLockShouldReclaim, isTransientLockFileDenial, maxTransientLockDenials, } from "./sidecar-lock-policy.js";
|
|
5
|
+
import { computeSidecarLockDelayMs, defaultSidecarLockShouldReclaim, isTransientLockFileDenial, maxTransientLockDenials, validateSidecarLockRetryOptions, validateSidecarLockTimeoutMs, } from "./sidecar-lock-policy.js";
|
|
6
6
|
import { readSidecarLockSnapshot, relativeSidecarLockPath, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
|
|
7
|
+
import { createSuppressedError } from "./suppressed-error.js";
|
|
7
8
|
async function resolveNormalizedTargetPath(targetPath) {
|
|
8
9
|
const resolved = path.resolve(targetPath);
|
|
9
10
|
const dir = path.dirname(resolved);
|
|
@@ -16,19 +17,34 @@ async function resolveNormalizedTargetPath(targetPath) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
export async function acquireSidecarLock(options, context) {
|
|
20
|
+
const retry = options.retry ?? {};
|
|
21
|
+
validateSidecarLockRetryOptions(retry);
|
|
22
|
+
validateSidecarLockTimeoutMs(options.timeoutMs);
|
|
19
23
|
context.ensureExitCleanupRegistered();
|
|
20
24
|
const normalizedTargetPath = await resolveNormalizedTargetPath(options.targetPath);
|
|
21
25
|
const lockPath = options.lockPath ?? `${normalizedTargetPath}.lock`;
|
|
22
|
-
|
|
26
|
+
let held = context.held.get(normalizedTargetPath);
|
|
23
27
|
if (held &&
|
|
24
28
|
options.reentrantOwner !== undefined &&
|
|
25
29
|
held.reentrantOwner !== undefined &&
|
|
26
30
|
options.reentrantOwner === held.reentrantOwner) {
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
// A final release may already have decremented the count to zero and be
|
|
32
|
+
// removing the sidecar. Do not admit a new reentrant handle until that
|
|
33
|
+
// cleanup settles: successful cleanup requires a fresh acquisition, while
|
|
34
|
+
// failed cleanup leaves the existing sidecar held for a retry.
|
|
35
|
+
if (held.releasePromise) {
|
|
36
|
+
await held.releasePromise.catch(() => undefined);
|
|
37
|
+
held = context.held.get(normalizedTargetPath);
|
|
38
|
+
}
|
|
39
|
+
if (held &&
|
|
40
|
+
options.reentrantOwner !== undefined &&
|
|
41
|
+
held.reentrantOwner !== undefined &&
|
|
42
|
+
options.reentrantOwner === held.reentrantOwner) {
|
|
43
|
+
held.refCount += 1;
|
|
44
|
+
return context.handleForHeldLock(normalizedTargetPath, held);
|
|
45
|
+
}
|
|
29
46
|
}
|
|
30
47
|
const startedAt = Date.now();
|
|
31
|
-
const retry = options.retry ?? {};
|
|
32
48
|
const maxRetries = options.timeoutMs === Number.POSITIVE_INFINITY ? undefined : retry.retries;
|
|
33
49
|
const reclaimGuardPath = `${lockPath}.reclaim`;
|
|
34
50
|
let ownsReclaimGuard = false;
|
|
@@ -149,31 +165,36 @@ export async function acquireSidecarLock(options, context) {
|
|
|
149
165
|
return returnedHandle;
|
|
150
166
|
}
|
|
151
167
|
catch (err) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
try {
|
|
169
|
+
if (handle) {
|
|
170
|
+
const failedSnapshot = { payload: null };
|
|
171
|
+
try {
|
|
172
|
+
failedSnapshot.stat = await handle.stat();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Best-effort cleanup of a failed exclusive create.
|
|
176
|
+
}
|
|
177
|
+
const current = context.held.get(normalizedTargetPath);
|
|
178
|
+
if (current?.handle === handle) {
|
|
179
|
+
context.held.delete(normalizedTargetPath);
|
|
180
|
+
}
|
|
181
|
+
await handle.close().catch(() => undefined);
|
|
182
|
+
// The file may be empty or partial JSON, so remove by the identity
|
|
183
|
+
// captured from our exclusive handle rather than by pathname alone.
|
|
184
|
+
await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
|
|
185
|
+
lockRoot: options.lockRoot,
|
|
186
|
+
parsePayload: options.parsePayload,
|
|
187
|
+
});
|
|
159
188
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
189
|
+
else if (createdSnapshot) {
|
|
190
|
+
await removeSidecarLockIfUnchanged(lockPath, createdSnapshot, {
|
|
191
|
+
lockRoot: options.lockRoot,
|
|
192
|
+
parsePayload: options.parsePayload,
|
|
193
|
+
});
|
|
163
194
|
}
|
|
164
|
-
await handle.close().catch(() => undefined);
|
|
165
|
-
// The file may be empty or partial JSON, so remove by the identity
|
|
166
|
-
// captured from our exclusive handle rather than by pathname alone.
|
|
167
|
-
await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
|
|
168
|
-
lockRoot: options.lockRoot,
|
|
169
|
-
parsePayload: options.parsePayload,
|
|
170
|
-
});
|
|
171
195
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
lockRoot: options.lockRoot,
|
|
175
|
-
parsePayload: options.parsePayload,
|
|
176
|
-
});
|
|
196
|
+
catch (cleanupError) {
|
|
197
|
+
throw createSuppressedError(cleanupError, err, "file lock acquisition and cleanup both failed");
|
|
177
198
|
}
|
|
178
199
|
if (lockFileCreateDenied && withinDenialBudget()) {
|
|
179
200
|
await retryOrRethrowDenial(err);
|
|
@@ -5,7 +5,9 @@ export declare function createSidecarLockHandle(params: {
|
|
|
5
5
|
lockPath: string;
|
|
6
6
|
normalizedTargetPath: string;
|
|
7
7
|
verifyStillHeld: () => Promise<boolean>;
|
|
8
|
-
release: (
|
|
8
|
+
release: (options?: {
|
|
9
|
+
retry?: boolean;
|
|
10
|
+
}) => Promise<unknown>;
|
|
9
11
|
}): SidecarLockHandle;
|
|
10
12
|
export declare function createHeldSidecarLockHandle(params: {
|
|
11
13
|
normalizedTargetPath: string;
|
|
@@ -15,6 +17,8 @@ export declare function createHeldSidecarLockHandle(params: {
|
|
|
15
17
|
lockRoot?: Root;
|
|
16
18
|
parsePayload?: (raw: string) => unknown;
|
|
17
19
|
};
|
|
18
|
-
release: (
|
|
20
|
+
release: (options?: {
|
|
21
|
+
retry?: boolean;
|
|
22
|
+
}) => Promise<unknown>;
|
|
19
23
|
}): SidecarLockHandle;
|
|
20
24
|
//# sourceMappingURL=sidecar-lock-handle.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock-handle.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-handle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"sidecar-lock-handle.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-handle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9D,GAAG,iBAAiB,CA2BpB;AAED,wBAAgB,2BAA2B,CAAC,MAAM,EAAE;IAClD,oBAAoB,EAAE,MAAM,CAAC;IAC7B,IAAI,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,mBAAmB,CAAC;QAC9B,QAAQ,CAAC,EAAE,IAAI,CAAC;QAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;KACzC,CAAC;IACF,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9D,GAAG,iBAAiB,CAWpB"}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { sidecarLockSnapshotStillPresent, } from "./sidecar-lock-reclaim.js";
|
|
2
2
|
export function createSidecarLockHandle(params) {
|
|
3
3
|
let released = false;
|
|
4
|
+
let releasePromise;
|
|
5
|
+
let releaseStarted = false;
|
|
4
6
|
const release = async () => {
|
|
5
7
|
if (released)
|
|
6
8
|
return;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
if (!releasePromise) {
|
|
10
|
+
const retry = releaseStarted;
|
|
11
|
+
releaseStarted = true;
|
|
12
|
+
releasePromise = (async () => {
|
|
13
|
+
await params.release({ retry });
|
|
14
|
+
released = true;
|
|
15
|
+
})();
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
await releasePromise;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
releasePromise = undefined;
|
|
22
|
+
}
|
|
9
23
|
};
|
|
10
24
|
return {
|
|
11
25
|
lockPath: params.lockPath,
|
|
@@ -23,6 +37,6 @@ export function createHeldSidecarLockHandle(params) {
|
|
|
23
37
|
lockRoot: params.held.lockRoot,
|
|
24
38
|
parsePayload: params.held.parsePayload,
|
|
25
39
|
}),
|
|
26
|
-
release: params.release,
|
|
40
|
+
release: (options) => params.release(options),
|
|
27
41
|
});
|
|
28
42
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
|
|
2
|
+
export declare function validateSidecarLockRetryOptions(retry: SidecarLockRetryOptions): void;
|
|
3
|
+
export declare function validateSidecarLockTimeoutMs(timeoutMs: number | undefined): void;
|
|
2
4
|
export declare function computeSidecarLockDelayMs(retry: SidecarLockRetryOptions, attempt: number): number;
|
|
3
5
|
export declare const maxTransientLockDenials = 8;
|
|
4
6
|
export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
|