@openclaw/fs-safe 0.2.3 → 0.2.4
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 +6 -0
- package/dist/archive-utils.d.ts +3 -0
- package/dist/archive-utils.d.ts.map +1 -0
- package/dist/archive-utils.js +26 -0
- package/dist/boundary-file-read.d.ts +44 -0
- package/dist/boundary-file-read.d.ts.map +1 -0
- package/dist/boundary-file-read.js +129 -0
- package/dist/boundary-path.d.ts +39 -0
- package/dist/boundary-path.d.ts.map +1 -0
- package/dist/boundary-path.js +598 -0
- package/dist/fs-pinned-path-helper.d.ts +7 -0
- package/dist/fs-pinned-path-helper.d.ts.map +1 -0
- package/dist/fs-pinned-path-helper.js +182 -0
- package/dist/fs-pinned-write-helper.d.ts +21 -0
- package/dist/fs-pinned-write-helper.d.ts.map +1 -0
- package/dist/fs-pinned-write-helper.js +263 -0
- package/dist/hardlink-guards.d.ts +7 -0
- package/dist/hardlink-guards.d.ts.map +1 -0
- package/dist/hardlink-guards.js +30 -0
- package/dist/install-safe-path.d.ts +20 -0
- package/dist/install-safe-path.d.ts.map +1 -0
- package/dist/install-safe-path.js +94 -0
- package/dist/json-file.d.ts +3 -0
- package/dist/json-file.d.ts.map +1 -0
- package/dist/json-file.js +123 -0
- package/dist/json-files.d.ts +20 -0
- package/dist/json-files.d.ts.map +1 -0
- package/dist/json-files.js +153 -0
- package/dist/path-alias-guards.d.ts +19 -0
- package/dist/path-alias-guards.d.ts.map +1 -0
- package/dist/path-alias-guards.js +21 -0
- package/dist/path-guards.d.ts +7 -0
- package/dist/path-guards.d.ts.map +1 -0
- package/dist/path-guards.js +49 -0
- package/dist/path-safety.d.ts +12 -0
- package/dist/path-safety.d.ts.map +1 -0
- package/dist/path-safety.js +50 -0
- package/dist/private-file-store.d.ts +7 -5
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +34 -21
- package/dist/safe-open-sync.d.ts +24 -0
- package/dist/safe-open-sync.d.ts.map +1 -0
- package/dist/safe-open-sync.js +71 -0
- package/dist/safe-root.d.ts +123 -0
- package/dist/safe-root.d.ts.map +1 -0
- package/dist/safe-root.js +1060 -0
- package/dist/secure-temp-workspace.d.ts +25 -0
- package/dist/secure-temp-workspace.d.ts.map +1 -0
- package/dist/secure-temp-workspace.js +136 -0
- package/dist/sibling-temp-file.d.ts +16 -0
- package/dist/sibling-temp-file.d.ts.map +1 -0
- package/dist/sibling-temp-file.js +73 -0
- package/dist/sibling-temp-write.d.ts +8 -0
- package/dist/sibling-temp-write.d.ts.map +1 -0
- package/dist/sibling-temp-write.js +40 -0
- package/dist/sidecar-lock.d.ts +8 -1
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +40 -7
- package/docs/config.md +2 -2
- package/docs/sidecar-lock.md +33 -5
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SecureTempWorkspaceOptions = {
|
|
2
|
+
rootDir: string;
|
|
3
|
+
prefix: string;
|
|
4
|
+
dirMode?: number;
|
|
5
|
+
fileMode?: number;
|
|
6
|
+
};
|
|
7
|
+
export type SecureTempWorkspace = {
|
|
8
|
+
dir: string;
|
|
9
|
+
path(fileName: string): string;
|
|
10
|
+
writePrivate(fileName: string, data: string | Uint8Array): Promise<string>;
|
|
11
|
+
read(fileName: string): Promise<Buffer>;
|
|
12
|
+
cleanup(): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export type SecureTempWorkspaceSync = {
|
|
15
|
+
dir: string;
|
|
16
|
+
path(fileName: string): string;
|
|
17
|
+
writePrivate(fileName: string, data: string | Uint8Array): string;
|
|
18
|
+
read(fileName: string): Buffer;
|
|
19
|
+
cleanup(): void;
|
|
20
|
+
};
|
|
21
|
+
export declare function createSecureTempWorkspace(options: SecureTempWorkspaceOptions): Promise<SecureTempWorkspace>;
|
|
22
|
+
export declare function withSecureTempWorkspace<T>(options: SecureTempWorkspaceOptions, run: (workspace: SecureTempWorkspace) => Promise<T>): Promise<T>;
|
|
23
|
+
export declare function createSecureTempWorkspaceSync(options: SecureTempWorkspaceOptions): SecureTempWorkspaceSync;
|
|
24
|
+
export declare function withSecureTempWorkspaceSync<T>(options: SecureTempWorkspaceOptions, run: (workspace: SecureTempWorkspaceSync) => T): T;
|
|
25
|
+
//# sourceMappingURL=secure-temp-workspace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-temp-workspace.d.ts","sourceRoot":"","sources":["../src/secure-temp-workspace.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,0BAA0B,GAAG;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3E,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IAClE,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,OAAO,IAAI,IAAI,CAAC;CACjB,CAAC;AAgDF,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,mBAAmB,CAAC,CA0B9B;AAED,wBAAsB,uBAAuB,CAAC,CAAC,EAC7C,OAAO,EAAE,0BAA0B,EACnC,GAAG,EAAE,CAAC,SAAS,EAAE,mBAAmB,KAAK,OAAO,CAAC,CAAC,CAAC,GAClD,OAAO,CAAC,CAAC,CAAC,CAUZ;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,0BAA0B,GAClC,uBAAuB,CAsCzB;AAED,wBAAgB,2BAA2B,CAAC,CAAC,EAC3C,OAAO,EAAE,0BAA0B,EACnC,GAAG,EAAE,CAAC,SAAS,EAAE,uBAAuB,KAAK,CAAC,GAC7C,CAAC,CAUH"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
function sanitizeTempPrefix(prefix) {
|
|
6
|
+
const sanitized = prefix.trim().replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
7
|
+
if (!sanitized || sanitized === "." || sanitized === "..") {
|
|
8
|
+
return "fs-safe-";
|
|
9
|
+
}
|
|
10
|
+
return sanitized.endsWith("-") ? sanitized : `${sanitized}-`;
|
|
11
|
+
}
|
|
12
|
+
function resolveWorkspaceLeaf(dir, fileName) {
|
|
13
|
+
const raw = fileName.trim();
|
|
14
|
+
if (!raw ||
|
|
15
|
+
raw === "." ||
|
|
16
|
+
raw === ".." ||
|
|
17
|
+
raw.includes("\0") ||
|
|
18
|
+
raw.includes("/") ||
|
|
19
|
+
raw.includes("\\") ||
|
|
20
|
+
path.basename(raw) !== raw) {
|
|
21
|
+
throw new Error(`Invalid temp workspace file name: ${JSON.stringify(fileName)}`);
|
|
22
|
+
}
|
|
23
|
+
return path.join(dir, raw);
|
|
24
|
+
}
|
|
25
|
+
async function ensurePrivateDirectory(dir, mode) {
|
|
26
|
+
await fs.mkdir(dir, { recursive: true, mode });
|
|
27
|
+
const stat = await fs.lstat(dir);
|
|
28
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
29
|
+
throw new Error(`Temp root must be a directory: ${dir}`);
|
|
30
|
+
}
|
|
31
|
+
await fs.chmod(dir, mode).catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
function ensurePrivateDirectorySync(dir, mode) {
|
|
34
|
+
fsSync.mkdirSync(dir, { recursive: true, mode });
|
|
35
|
+
const stat = fsSync.lstatSync(dir);
|
|
36
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
37
|
+
throw new Error(`Temp root must be a directory: ${dir}`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
fsSync.chmodSync(dir, mode);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Best-effort on platforms that do not enforce POSIX modes.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function createSecureTempWorkspace(options) {
|
|
47
|
+
const dirMode = options.dirMode ?? 0o700;
|
|
48
|
+
const fileMode = options.fileMode ?? 0o600;
|
|
49
|
+
const root = path.resolve(options.rootDir);
|
|
50
|
+
await ensurePrivateDirectory(root, dirMode);
|
|
51
|
+
const dir = await fs.mkdtemp(path.join(root, sanitizeTempPrefix(options.prefix)));
|
|
52
|
+
await fs.chmod(dir, dirMode).catch(() => undefined);
|
|
53
|
+
const stat = await fs.lstat(dir);
|
|
54
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
55
|
+
throw new Error(`Temp workspace must be a directory: ${dir}`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
dir,
|
|
59
|
+
path: (fileName) => resolveWorkspaceLeaf(dir, fileName),
|
|
60
|
+
writePrivate: async (fileName, data) => {
|
|
61
|
+
const filePath = resolveWorkspaceLeaf(dir, fileName);
|
|
62
|
+
await fs.writeFile(filePath, data, { mode: fileMode, flag: "wx" });
|
|
63
|
+
await fs.chmod(filePath, fileMode).catch(() => undefined);
|
|
64
|
+
return filePath;
|
|
65
|
+
},
|
|
66
|
+
read: async (fileName) => await fs.readFile(resolveWorkspaceLeaf(dir, fileName)),
|
|
67
|
+
cleanup: async () => {
|
|
68
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export async function withSecureTempWorkspace(options, run) {
|
|
73
|
+
const workspace = await createSecureTempWorkspace({
|
|
74
|
+
...options,
|
|
75
|
+
prefix: `${sanitizeTempPrefix(options.prefix)}${randomUUID()}-`,
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
return await run(workspace);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await workspace.cleanup();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function createSecureTempWorkspaceSync(options) {
|
|
85
|
+
const dirMode = options.dirMode ?? 0o700;
|
|
86
|
+
const fileMode = options.fileMode ?? 0o600;
|
|
87
|
+
const root = path.resolve(options.rootDir);
|
|
88
|
+
ensurePrivateDirectorySync(root, dirMode);
|
|
89
|
+
const dir = fsSync.mkdtempSync(path.join(root, sanitizeTempPrefix(options.prefix)));
|
|
90
|
+
try {
|
|
91
|
+
fsSync.chmodSync(dir, dirMode);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Best-effort on platforms that do not enforce POSIX modes.
|
|
95
|
+
}
|
|
96
|
+
const stat = fsSync.lstatSync(dir);
|
|
97
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
98
|
+
throw new Error(`Temp workspace must be a directory: ${dir}`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
dir,
|
|
102
|
+
path: (fileName) => resolveWorkspaceLeaf(dir, fileName),
|
|
103
|
+
writePrivate: (fileName, data) => {
|
|
104
|
+
const filePath = resolveWorkspaceLeaf(dir, fileName);
|
|
105
|
+
fsSync.writeFileSync(filePath, data, { mode: fileMode, flag: "wx" });
|
|
106
|
+
try {
|
|
107
|
+
fsSync.chmodSync(filePath, fileMode);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Best-effort on platforms that do not enforce POSIX modes.
|
|
111
|
+
}
|
|
112
|
+
return filePath;
|
|
113
|
+
},
|
|
114
|
+
read: (fileName) => fsSync.readFileSync(resolveWorkspaceLeaf(dir, fileName)),
|
|
115
|
+
cleanup: () => {
|
|
116
|
+
try {
|
|
117
|
+
fsSync.rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Best-effort cleanup.
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function withSecureTempWorkspaceSync(options, run) {
|
|
126
|
+
const workspace = createSecureTempWorkspaceSync({
|
|
127
|
+
...options,
|
|
128
|
+
prefix: `${sanitizeTempPrefix(options.prefix)}${randomUUID()}-`,
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
return run(workspace);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
workspace.cleanup();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type WriteSiblingTempFileOptions<T> = {
|
|
2
|
+
dir: string;
|
|
3
|
+
writeTemp: (tempPath: string) => Promise<T>;
|
|
4
|
+
resolveFinalPath: (result: T) => string;
|
|
5
|
+
tempPrefix?: string;
|
|
6
|
+
dirMode?: number;
|
|
7
|
+
fileMode?: number;
|
|
8
|
+
syncTempFile?: boolean;
|
|
9
|
+
syncParentDir?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type WriteSiblingTempFileResult<T> = {
|
|
12
|
+
filePath: string;
|
|
13
|
+
result: T;
|
|
14
|
+
};
|
|
15
|
+
export declare function writeSiblingTempFile<T>(options: WriteSiblingTempFileOptions<T>): Promise<WriteSiblingTempFileResult<T>>;
|
|
16
|
+
//# sourceMappingURL=sibling-temp-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sibling-temp-file.d.ts","sourceRoot":"","sources":["../src/sibling-temp-file.ts"],"names":[],"mappings":"AAIA,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,MAAM,CAAC;IAClB,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;AAuCF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CA+BxC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function buildTempPath(dir, tempPrefix) {
|
|
5
|
+
return path.join(dir, `${tempPrefix ?? ".fs-safe-stream"}.${process.pid}.${randomUUID()}.tmp`);
|
|
6
|
+
}
|
|
7
|
+
async function syncFileBestEffort(filePath) {
|
|
8
|
+
const handle = await fs.open(filePath, "r+");
|
|
9
|
+
try {
|
|
10
|
+
await handle.sync();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== "EPERM") {
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await handle.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function syncDirectoryBestEffort(dirPath) {
|
|
22
|
+
let handle;
|
|
23
|
+
try {
|
|
24
|
+
handle = await fs.open(dirPath, "r");
|
|
25
|
+
await handle.sync();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Best-effort on platforms/filesystems that do not support directory fsync.
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await handle?.close().catch(() => undefined);
|
|
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
|
+
export async function writeSiblingTempFile(options) {
|
|
42
|
+
const dir = path.resolve(options.dir);
|
|
43
|
+
await fs.mkdir(dir, { recursive: true, mode: options.dirMode ?? 0o700 });
|
|
44
|
+
await fs.chmod(dir, options.dirMode ?? 0o700).catch(() => undefined);
|
|
45
|
+
const tempPath = buildTempPath(dir, options.tempPrefix);
|
|
46
|
+
let tempExists = false;
|
|
47
|
+
try {
|
|
48
|
+
tempExists = true;
|
|
49
|
+
const result = await options.writeTemp(tempPath);
|
|
50
|
+
if (options.fileMode !== undefined) {
|
|
51
|
+
await fs.chmod(tempPath, options.fileMode).catch(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
if (options.syncTempFile) {
|
|
54
|
+
await syncFileBestEffort(tempPath);
|
|
55
|
+
}
|
|
56
|
+
const filePath = path.resolve(options.resolveFinalPath(result));
|
|
57
|
+
assertFinalPathIsSibling(dir, filePath);
|
|
58
|
+
await fs.rename(tempPath, filePath);
|
|
59
|
+
tempExists = false;
|
|
60
|
+
if (options.fileMode !== undefined) {
|
|
61
|
+
await fs.chmod(filePath, options.fileMode).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
if (options.syncParentDir) {
|
|
64
|
+
await syncDirectoryBestEffort(dir);
|
|
65
|
+
}
|
|
66
|
+
return { filePath, result };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (tempExists) {
|
|
70
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function writeViaSiblingTempPath(params: {
|
|
2
|
+
rootDir: string;
|
|
3
|
+
targetPath: string;
|
|
4
|
+
writeTemp: (tempPath: string) => Promise<void>;
|
|
5
|
+
fallbackFileName?: string;
|
|
6
|
+
tempPrefix?: string;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
//# sourceMappingURL=sibling-temp-write.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sibling-temp-write.d.ts","sourceRoot":"","sources":["../src/sibling-temp-write.ts"],"names":[],"mappings":"AAmBA,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,CA8BhB"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sanitizeUntrustedFileName } from "./filename.js";
|
|
5
|
+
import { safeRoot } from "./safe-root.js";
|
|
6
|
+
function buildSiblingTempPath(params) {
|
|
7
|
+
const id = crypto.randomUUID();
|
|
8
|
+
const safeTail = sanitizeUntrustedFileName(path.basename(params.targetPath), params.fallbackFileName);
|
|
9
|
+
return path.join(path.dirname(params.targetPath), `${params.tempPrefix}${id}-${safeTail}.part`);
|
|
10
|
+
}
|
|
11
|
+
export async function writeViaSiblingTempPath(params) {
|
|
12
|
+
const rootDir = await fs
|
|
13
|
+
.realpath(path.resolve(params.rootDir))
|
|
14
|
+
.catch(() => path.resolve(params.rootDir));
|
|
15
|
+
const requestedTargetPath = path.resolve(params.targetPath);
|
|
16
|
+
const targetPath = await fs
|
|
17
|
+
.realpath(path.dirname(requestedTargetPath))
|
|
18
|
+
.then((realDir) => path.join(realDir, path.basename(requestedTargetPath)))
|
|
19
|
+
.catch(() => requestedTargetPath);
|
|
20
|
+
const relativeTargetPath = path.relative(rootDir, targetPath);
|
|
21
|
+
if (!relativeTargetPath ||
|
|
22
|
+
relativeTargetPath === ".." ||
|
|
23
|
+
relativeTargetPath.startsWith(`..${path.sep}`) ||
|
|
24
|
+
path.isAbsolute(relativeTargetPath)) {
|
|
25
|
+
throw new Error("Target path is outside the allowed root");
|
|
26
|
+
}
|
|
27
|
+
const tempPath = buildSiblingTempPath({
|
|
28
|
+
targetPath,
|
|
29
|
+
fallbackFileName: params.fallbackFileName ?? "output.bin",
|
|
30
|
+
tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
await params.writeTemp(tempPath);
|
|
34
|
+
const root = await safeRoot(rootDir);
|
|
35
|
+
await root.copyFileFrom(tempPath, relativeTargetPath, { mkdir: false });
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/sidecar-lock.d.ts
CHANGED
|
@@ -5,7 +5,13 @@ export type SidecarLockRetryOptions = {
|
|
|
5
5
|
maxTimeout?: number;
|
|
6
6
|
randomize?: boolean;
|
|
7
7
|
};
|
|
8
|
-
export type SidecarLockStaleRecovery = "fail-closed";
|
|
8
|
+
export type SidecarLockStaleRecovery = "fail-closed" | "remove-if-unchanged";
|
|
9
|
+
export type SidecarLockStaleSnapshot = {
|
|
10
|
+
lockPath: string;
|
|
11
|
+
normalizedTargetPath: string;
|
|
12
|
+
raw: string;
|
|
13
|
+
payload: Record<string, unknown> | null;
|
|
14
|
+
};
|
|
9
15
|
export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
10
16
|
targetPath: string;
|
|
11
17
|
lockPath?: string;
|
|
@@ -23,6 +29,7 @@ export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>>
|
|
|
23
29
|
nowMs: number;
|
|
24
30
|
heldByThisProcess: boolean;
|
|
25
31
|
}) => boolean | Promise<boolean>;
|
|
32
|
+
shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
|
|
26
33
|
metadata?: Record<string, unknown>;
|
|
27
34
|
};
|
|
28
35
|
export type SidecarLockHandle = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,aAAa,CAAC;
|
|
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
|
@@ -64,6 +64,33 @@ async function lockSnapshotStillPresent(lockPath, observed) {
|
|
|
64
64
|
const current = await readLockSnapshot(lockPath);
|
|
65
65
|
return !!current && !!observed && snapshotMatches(current, observed);
|
|
66
66
|
}
|
|
67
|
+
async function removeStaleLockIfAllowed(params) {
|
|
68
|
+
if (!params.shouldRemoveStaleLock) {
|
|
69
|
+
return "not-approved";
|
|
70
|
+
}
|
|
71
|
+
if (params.snapshot.raw === undefined) {
|
|
72
|
+
return "not-approved";
|
|
73
|
+
}
|
|
74
|
+
if (!(await params.shouldRemoveStaleLock({
|
|
75
|
+
lockPath: params.lockPath,
|
|
76
|
+
normalizedTargetPath: params.normalizedTargetPath,
|
|
77
|
+
raw: params.snapshot.raw,
|
|
78
|
+
payload: params.snapshot.payload,
|
|
79
|
+
}))) {
|
|
80
|
+
return "not-approved";
|
|
81
|
+
}
|
|
82
|
+
const current = await readLockSnapshot(params.lockPath);
|
|
83
|
+
if (!current || !snapshotMatches(current, params.snapshot)) {
|
|
84
|
+
return "changed";
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await fs.rm(params.lockPath, { force: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return "not-approved";
|
|
91
|
+
}
|
|
92
|
+
return "removed";
|
|
93
|
+
}
|
|
67
94
|
function snapshotMatchesSync(lockPath, observed) {
|
|
68
95
|
try {
|
|
69
96
|
const stat = fsSync.lstatSync(lockPath);
|
|
@@ -248,17 +275,23 @@ export function createSidecarLockManager(key) {
|
|
|
248
275
|
if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
|
|
249
276
|
continue;
|
|
250
277
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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",
|
|
278
|
+
const staleRecovery = options.staleRecovery ?? "fail-closed";
|
|
279
|
+
if (staleRecovery === "remove-if-unchanged") {
|
|
280
|
+
const removal = await removeStaleLockIfAllowed({
|
|
258
281
|
lockPath,
|
|
259
282
|
normalizedTargetPath,
|
|
283
|
+
snapshot,
|
|
284
|
+
shouldRemoveStaleLock: options.shouldRemoveStaleLock,
|
|
260
285
|
});
|
|
286
|
+
if (removal === "removed" || removal === "changed") {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
261
289
|
}
|
|
290
|
+
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
|
291
|
+
code: "file_lock_stale",
|
|
292
|
+
lockPath,
|
|
293
|
+
normalizedTargetPath,
|
|
294
|
+
});
|
|
262
295
|
}
|
|
263
296
|
const elapsed = Date.now() - startedAt;
|
|
264
297
|
if ((options.timeoutMs !== undefined &&
|
package/docs/config.md
CHANGED
|
@@ -56,7 +56,7 @@ Return the effective configuration: programmatic overrides win, then env vars, t
|
|
|
56
56
|
function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
|
57
57
|
|
|
58
58
|
type FsSafeLockConfig = {
|
|
59
|
-
staleRecovery: "fail-closed";
|
|
59
|
+
staleRecovery: "fail-closed" | "remove-if-unchanged";
|
|
60
60
|
staleMs?: number;
|
|
61
61
|
timeoutMs?: number;
|
|
62
62
|
retry?: FileLockRetryOptions;
|
|
@@ -65,7 +65,7 @@ type FsSafeLockConfig = {
|
|
|
65
65
|
|
|
66
66
|
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
|
67
67
|
|
|
68
|
-
`staleRecovery`
|
|
68
|
+
`staleRecovery` defaults to `"fail-closed"`. `"remove-if-unchanged"` is available for callers that also pass `shouldRemoveStaleLock`; fs-safe re-reads the observed sidecar and removes it only when the raw content and file identity still match.
|
|
69
69
|
|
|
70
70
|
## `getFsSafeLockConfig()`
|
|
71
71
|
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -51,7 +51,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
51
51
|
staleMs?: number; // default 30_000
|
|
52
52
|
timeoutMs?: number; // overall acquire deadline; default unbounded
|
|
53
53
|
retry?: FileLockRetryOptions;
|
|
54
|
-
staleRecovery?: "fail-closed";
|
|
54
|
+
staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
|
|
55
55
|
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
|
|
56
56
|
payload: () => TPayload | Promise<TPayload>;
|
|
57
57
|
shouldReclaim?: (params: {
|
|
@@ -62,6 +62,12 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
62
62
|
nowMs: number;
|
|
63
63
|
heldByThisProcess: boolean;
|
|
64
64
|
}) => boolean | Promise<boolean>;
|
|
65
|
+
shouldRemoveStaleLock?: (snapshot: {
|
|
66
|
+
lockPath: string;
|
|
67
|
+
normalizedTargetPath: string;
|
|
68
|
+
raw: string;
|
|
69
|
+
payload: Record<string, unknown> | null;
|
|
70
|
+
}) => boolean | Promise<boolean>;
|
|
65
71
|
metadata?: Record<string, unknown>; // attached to heldEntries() output for diagnostics
|
|
66
72
|
};
|
|
67
73
|
|
|
@@ -101,7 +107,7 @@ try {
|
|
|
101
107
|
}
|
|
102
108
|
```
|
|
103
109
|
|
|
104
|
-
If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed instead of deleting by path
|
|
110
|
+
If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed by default instead of deleting by path.
|
|
105
111
|
|
|
106
112
|
## `withFileLock` — common shape made one-liner
|
|
107
113
|
|
|
@@ -164,18 +170,40 @@ const handle = await acquireFileLock(targetPath, {
|
|
|
164
170
|
});
|
|
165
171
|
```
|
|
166
172
|
|
|
167
|
-
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result
|
|
173
|
+
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale; `staleRecovery` then decides whether acquisition fails closed or tries caller-approved removal.
|
|
174
|
+
|
|
175
|
+
## Stale recovery: `remove-if-unchanged`
|
|
176
|
+
|
|
177
|
+
The default `staleRecovery: "fail-closed"` never removes third-party sidecars. Use `staleRecovery: "remove-if-unchanged"` only when your app has a reliable owner-liveness policy and can prove a stale owner cannot still be writing.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const handle = await acquireFileLock(targetPath, {
|
|
181
|
+
staleMs: 60_000,
|
|
182
|
+
staleRecovery: "remove-if-unchanged",
|
|
183
|
+
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
|
|
184
|
+
shouldReclaim: ({ payload }) => {
|
|
185
|
+
const pid = Number(payload?.pid);
|
|
186
|
+
return Number.isInteger(pid) && pid > 0 && ownerIsDefinitelyDead(pid);
|
|
187
|
+
},
|
|
188
|
+
shouldRemoveStaleLock: ({ payload }) => {
|
|
189
|
+
const pid = Number(payload?.pid);
|
|
190
|
+
return Number.isInteger(pid) && pid > 0 && ownerIsDefinitelyDead(pid);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`shouldRemoveStaleLock` receives the exact lock snapshot that `fs-safe` inspected. `fs-safe` re-reads the sidecar and removes it only if the raw content and file identity are unchanged. If the callback is missing, returns false, or the file changed, acquisition fails closed or keeps retrying according to the normal retry policy.
|
|
168
196
|
|
|
169
197
|
## What sidecar locks defend against
|
|
170
198
|
|
|
171
199
|
- **Two processes writing the same file at once.** `acquire` serializes the critical section.
|
|
172
|
-
- **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks fail closed
|
|
200
|
+
- **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks fail closed by default. Opt-in removal rechecks the observed snapshot before unlinking.
|
|
173
201
|
- **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
|
|
174
202
|
|
|
175
203
|
## What they do **not** defend against
|
|
176
204
|
|
|
177
205
|
- **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
|
|
178
|
-
- **Automatic stale lock deletion.** If a process crashes, use the payload and your own supervisor/process table to decide when
|
|
206
|
+
- **Automatic stale lock deletion.** If a process crashes, use the payload and your own supervisor/process table to decide when removal is safe, then opt into `remove-if-unchanged`.
|
|
179
207
|
- **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
|
|
180
208
|
|
|
181
209
|
## Common patterns
|