@openclaw/fs-safe 0.4.0 → 0.4.1
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 +10 -0
- package/dist/file-identity.d.ts +1 -0
- package/dist/file-identity.d.ts.map +1 -1
- package/dist/file-identity.js +10 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/pinned-write.d.ts +9 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +77 -4
- package/dist/root-impl.d.ts +4 -1
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +10 -7
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +1 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/docs/security-model.md +1 -0
- package/docs/writing.md +21 -1
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1 - 2026-07-01
|
|
4
|
+
|
|
5
|
+
### Security and Correctness
|
|
6
|
+
|
|
7
|
+
- Update the optional TAR extractor to reject NUL-terminated PAX values, invalid negative entry sizes, and explosive decompression payloads.
|
|
8
|
+
|
|
9
|
+
### Compatibility
|
|
10
|
+
|
|
11
|
+
- Add explicit `renameIdentity: "verify-content-with-lock"` writes for rclone-style FUSE mounts whose inode identity changes across rename, while keeping strict identity verification as the default and failing closed on stale cooperative locks. (#32, #33; thanks @jlautman)
|
|
12
|
+
|
|
3
13
|
## 0.4.0 - 2026-06-17
|
|
4
14
|
|
|
5
15
|
### Features
|
package/dist/file-identity.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ export type FileIdentityStat = {
|
|
|
2
2
|
dev: number | bigint;
|
|
3
3
|
ino: number | bigint;
|
|
4
4
|
};
|
|
5
|
+
export declare function sha256Hex(data: string | Buffer, encoding?: BufferEncoding): string;
|
|
5
6
|
export declare function sameFileIdentity(left: FileIdentityStat, right: FileIdentityStat, platform?: NodeJS.Platform): boolean;
|
|
6
7
|
//# sourceMappingURL=file-identity.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-identity.d.ts","sourceRoot":"","sources":["../src/file-identity.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"file-identity.d.ts","sourceRoot":"","sources":["../src/file-identity.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;CACtB,CAAC;AAUF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,QAAQ,CAAC,EAAE,cAAc,GAAG,MAAM,CAGlF;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,gBAAgB,EACtB,KAAK,EAAE,gBAAgB,EACvB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAWT"}
|
package/dist/file-identity.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
function isZero(value) {
|
|
2
3
|
return value === 0 || value === 0n;
|
|
3
4
|
}
|
|
5
|
+
function sameStatValue(left, right) {
|
|
6
|
+
return typeof left === typeof right ? left === right : BigInt(left) === BigInt(right);
|
|
7
|
+
}
|
|
8
|
+
export function sha256Hex(data, encoding) {
|
|
9
|
+
const buffer = typeof data === "string" ? Buffer.from(data, encoding ?? "utf8") : data;
|
|
10
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
11
|
+
}
|
|
4
12
|
export function sameFileIdentity(left, right, platform = process.platform) {
|
|
5
|
-
if (left.ino
|
|
13
|
+
if (!sameStatValue(left.ino, right.ino)) {
|
|
6
14
|
return false;
|
|
7
15
|
}
|
|
8
16
|
// On Windows, path-based stat calls can report dev=0 while fd-based stat
|
|
9
17
|
// reports a real volume serial; treat either-side dev=0 as "unknown device".
|
|
10
|
-
if (left.dev
|
|
18
|
+
if (sameStatValue(left.dev, right.dev)) {
|
|
11
19
|
return true;
|
|
12
20
|
}
|
|
13
21
|
return platform === "win32" && (isZero(left.dev) || isZero(right.dev));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { FsSafeError, categorizeFsSafeError, type FsSafeErrorCategory, type FsSafeErrorCode, } from "./errors.js";
|
|
2
|
-
export { DEFAULT_ROOT_MAX_BYTES, root, type DenyMutationPolicy, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
|
|
2
|
+
export { DEFAULT_ROOT_MAX_BYTES, root, type DenyMutationPolicy, type HardlinkPolicy, type RenameIdentityPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
|
|
3
3
|
export { configureFsSafePython, getFsSafePythonConfig, type FsSafePythonConfig, type FsSafePythonMode, } from "./pinned-python-config.js";
|
|
4
4
|
export { writeExternalFileWithinRoot, type ExternalFileWriteOptions, type ExternalFileWriteResult, } from "./output.js";
|
|
5
5
|
export { configureFsSafeLocks, getFsSafeLockConfig, type FsSafeLockConfig, } from "./lock-config.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
|
package/dist/pinned-write.d.ts
CHANGED
|
@@ -8,7 +8,9 @@ type PinnedWriteInput = {
|
|
|
8
8
|
kind: "stream";
|
|
9
9
|
stream: Readable;
|
|
10
10
|
};
|
|
11
|
-
|
|
11
|
+
type RenameIdentityMismatchPolicy = "throw" | "verify-content";
|
|
12
|
+
export type RenameIdentityPolicy = "strict" | "verify-content-with-lock";
|
|
13
|
+
type PinnedWriteParams = {
|
|
12
14
|
rootPath: string;
|
|
13
15
|
relativeParentPath: string;
|
|
14
16
|
basename: string;
|
|
@@ -18,6 +20,12 @@ export declare function runPinnedWriteHelper(params: {
|
|
|
18
20
|
maxBytes?: number;
|
|
19
21
|
input: PinnedWriteInput;
|
|
20
22
|
rootIdentity?: FileIdentityStat;
|
|
23
|
+
onRenameIdentityMismatch?: RenameIdentityMismatchPolicy;
|
|
24
|
+
};
|
|
25
|
+
export declare function runPinnedWriteHelper(params: PinnedWriteParams): Promise<FileIdentityStat>;
|
|
26
|
+
export declare function runPinnedWriteWithRenamePolicy(params: PinnedWriteParams & {
|
|
27
|
+
targetPath: string;
|
|
28
|
+
renameIdentity?: RenameIdentityPolicy;
|
|
21
29
|
}): Promise<FileIdentityStat>;
|
|
22
30
|
export declare function runPinnedCopyHelper(params: {
|
|
23
31
|
rootPath: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAI5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAI5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAa3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AA6EzC,KAAK,4BAA4B,GAAG,OAAO,GAAG,gBAAgB,CAAC;AAE/D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,0BAA0B,CAAC;AAEzE,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;IACxB,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC,wBAAwB,CAAC,EAAE,4BAA4B,CAAC;CACzD,CAAC;AAEF,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAkD/F;AAED,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,iBAAiB,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,oBAAoB,CAAC;CACvC,GACA,OAAO,CAAC,gBAAgB,CAAC,CA2B3B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;IACjC,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqB5B"}
|
package/dist/pinned-write.js
CHANGED
|
@@ -5,11 +5,13 @@ import path from "node:path";
|
|
|
5
5
|
import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
7
|
import { syncDirectoryBestEffort } from "./fsync.js";
|
|
8
|
-
import { sameFileIdentity } from "./file-identity.js";
|
|
8
|
+
import { sameFileIdentity, sha256Hex } from "./file-identity.js";
|
|
9
9
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
10
10
|
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
11
11
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
12
12
|
import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
|
|
13
|
+
import { withSidecarLock } from "./sidecar-lock.js";
|
|
14
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
13
15
|
function byteLength(input, encoding) {
|
|
14
16
|
return typeof input === "string"
|
|
15
17
|
? Buffer.byteLength(input, encoding ?? "utf8")
|
|
@@ -67,6 +69,12 @@ export async function runPinnedWriteHelper(params) {
|
|
|
67
69
|
validatePinnedOperationPayload({
|
|
68
70
|
relativeParentPath: params.relativeParentPath,
|
|
69
71
|
});
|
|
72
|
+
// The Python helper deliberately enforces the strict post-rename inode
|
|
73
|
+
// contract. The explicit compatibility policy therefore uses the guarded
|
|
74
|
+
// Node fallback, where content verification can replace that one check.
|
|
75
|
+
if (params.onRenameIdentityMismatch === "verify-content") {
|
|
76
|
+
return await runPinnedWriteFallback(params);
|
|
77
|
+
}
|
|
70
78
|
if (getFsSafePythonConfig().mode === "off") {
|
|
71
79
|
return await runPinnedWriteFallback(params);
|
|
72
80
|
}
|
|
@@ -81,8 +89,11 @@ export async function runPinnedWriteHelper(params) {
|
|
|
81
89
|
throw error;
|
|
82
90
|
}
|
|
83
91
|
}
|
|
92
|
+
const input = params.input.kind === "stream"
|
|
93
|
+
? { kind: "buffer", data: Buffer.from(await inputToBase64(params.input, params.maxBytes), "base64") }
|
|
94
|
+
: params.input;
|
|
84
95
|
const payload = {
|
|
85
|
-
base64: await inputToBase64(
|
|
96
|
+
base64: await inputToBase64(input, params.maxBytes),
|
|
86
97
|
basename: params.basename,
|
|
87
98
|
maxBytes: params.maxBytes ?? -1,
|
|
88
99
|
mkdir: params.mkdir,
|
|
@@ -100,11 +111,32 @@ export async function runPinnedWriteHelper(params) {
|
|
|
100
111
|
}
|
|
101
112
|
catch (error) {
|
|
102
113
|
if (canFallbackFromPythonError(error)) {
|
|
103
|
-
return await runPinnedWriteFallback(params);
|
|
114
|
+
return await runPinnedWriteFallback({ ...params, input });
|
|
104
115
|
}
|
|
105
116
|
throw error;
|
|
106
117
|
}
|
|
107
118
|
}
|
|
119
|
+
export async function runPinnedWriteWithRenamePolicy(params) {
|
|
120
|
+
const { targetPath, renameIdentity, ...writeParams } = params;
|
|
121
|
+
if (renameIdentity !== "verify-content-with-lock") {
|
|
122
|
+
return await runPinnedWriteHelper(writeParams);
|
|
123
|
+
}
|
|
124
|
+
const relativeTargetPath = writeParams.relativeParentPath
|
|
125
|
+
? `${writeParams.relativeParentPath}/${writeParams.basename}`
|
|
126
|
+
: writeParams.basename;
|
|
127
|
+
const lockPath = path.join(writeParams.rootPath, `.fs-safe-write-${sha256Hex(relativeTargetPath)}.lock`);
|
|
128
|
+
return await withSidecarLock(writeParams.rootPath, {
|
|
129
|
+
managerKey: `fs-safe.write:${targetPath}`,
|
|
130
|
+
lockPath,
|
|
131
|
+
staleMs: 30_000,
|
|
132
|
+
timeoutMs: 5_000,
|
|
133
|
+
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
|
|
134
|
+
retry: { retries: 5, minTimeout: 100, maxTimeout: 2_000, factor: 2 },
|
|
135
|
+
}, async () => await runPinnedWriteHelper({
|
|
136
|
+
...writeParams,
|
|
137
|
+
onRenameIdentityMismatch: "verify-content",
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
108
140
|
export async function runPinnedCopyHelper(params) {
|
|
109
141
|
assertSafeBasename(params.basename);
|
|
110
142
|
validatePinnedOperationPayload({
|
|
@@ -160,7 +192,10 @@ async function runPinnedWriteFallback(params) {
|
|
|
160
192
|
else {
|
|
161
193
|
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
|
|
162
194
|
}
|
|
195
|
+
await handle.sync();
|
|
163
196
|
const stat = await handle.stat();
|
|
197
|
+
await handle.close().catch(() => undefined);
|
|
198
|
+
await syncDirectoryBestEffort(parentPath);
|
|
164
199
|
created = false;
|
|
165
200
|
return { dev: stat.dev, ino: stat.ino };
|
|
166
201
|
}
|
|
@@ -208,11 +243,49 @@ async function runPinnedWriteFallback(params) {
|
|
|
208
243
|
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
209
244
|
await fs.rename(tempPath, targetPath);
|
|
210
245
|
renamed = true;
|
|
246
|
+
await getFsSafeTestHooks()?.afterPinnedWriteFallbackRename?.(targetPath);
|
|
211
247
|
await syncDirectoryBestEffort(parentPath);
|
|
212
248
|
targetStat = await fs.lstat(targetPath);
|
|
213
|
-
if (targetStat.isSymbolicLink()
|
|
249
|
+
if (targetStat.isSymbolicLink()) {
|
|
214
250
|
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
215
251
|
}
|
|
252
|
+
if (!sameFileIdentity(targetStat, expectedTempStat)) {
|
|
253
|
+
// On filesystems like rclone FUSE, rename(2) can give the destination a
|
|
254
|
+
// different inode from the source temp fd even with zero concurrency. The
|
|
255
|
+
// caller must ensure mutual exclusion before passing "verify-content";
|
|
256
|
+
// fall back to a content hash for this rename-boundary check only.
|
|
257
|
+
if (params.onRenameIdentityMismatch !== "verify-content") {
|
|
258
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
259
|
+
}
|
|
260
|
+
if (params.input.kind !== "buffer") {
|
|
261
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
262
|
+
}
|
|
263
|
+
const expectedHash = sha256Hex(params.input.data, params.input.encoding);
|
|
264
|
+
const readFlags = fsSync.constants.O_RDONLY |
|
|
265
|
+
(process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
|
|
266
|
+
? fsSync.constants.O_NOFOLLOW
|
|
267
|
+
: 0);
|
|
268
|
+
const readHandle = await fs.open(targetPath, readFlags);
|
|
269
|
+
let actualHash;
|
|
270
|
+
let readHandleStat;
|
|
271
|
+
try {
|
|
272
|
+
// Capture fd-based identity before reading — this is stable across all
|
|
273
|
+
// subsequent lookups (on FUSE and locally), unlike the lstat-based
|
|
274
|
+
// targetStat that triggered this fallback.
|
|
275
|
+
readHandleStat = await readHandle.stat();
|
|
276
|
+
actualHash = sha256Hex(await readHandle.readFile());
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
await readHandle.close().catch(() => undefined);
|
|
280
|
+
}
|
|
281
|
+
if (actualHash !== expectedHash) {
|
|
282
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
283
|
+
}
|
|
284
|
+
// Replace the unreliable lstat-based targetStat with the fd-based stat so
|
|
285
|
+
// the returned identity is consistent with what subsequent verifications
|
|
286
|
+
// (e.g. verifyAtomicWriteResult) will obtain by opening the same file.
|
|
287
|
+
targetStat = readHandleStat;
|
|
288
|
+
}
|
|
216
289
|
});
|
|
217
290
|
}
|
|
218
291
|
catch (error) {
|
package/dist/root-impl.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Stats } from "node:fs";
|
|
2
2
|
import type { FileHandle } from "node:fs/promises";
|
|
3
3
|
import { type DenyMutationPolicy } from "./deny-mutations.js";
|
|
4
|
+
import { type RenameIdentityPolicy } from "./pinned-write.js";
|
|
4
5
|
import { type ReadResult } from "./read-opened-file.js";
|
|
5
6
|
import type { DirEntry, PathStat } from "./types.js";
|
|
6
7
|
export type { DenyMutationPolicy } from "./deny-mutations.js";
|
|
8
|
+
export type { RenameIdentityPolicy } from "./pinned-write.js";
|
|
7
9
|
export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
8
10
|
export type { ReadResult } from "./read-opened-file.js";
|
|
9
11
|
export type OpenResult = {
|
|
@@ -26,11 +28,12 @@ export type RootDefaults = {
|
|
|
26
28
|
mode?: number;
|
|
27
29
|
denyMutations?: DenyMutationPolicy;
|
|
28
30
|
nonBlockingRead?: boolean;
|
|
31
|
+
renameIdentity?: RenameIdentityPolicy;
|
|
29
32
|
symlinks?: SymlinkPolicy;
|
|
30
33
|
};
|
|
31
34
|
export type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
|
|
32
35
|
export type RootOpenOptions = Omit<RootReadOptions, "maxBytes">;
|
|
33
|
-
export type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
36
|
+
export type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode" | "renameIdentity"> & {
|
|
34
37
|
encoding?: BufferEncoding;
|
|
35
38
|
overwrite?: boolean;
|
|
36
39
|
};
|
package/dist/root-impl.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAWnD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
|
package/dist/root-impl.js
CHANGED
|
@@ -13,7 +13,7 @@ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
|
13
13
|
import { assertMutationNotDenied, mergeDenyMutationPolicies, } from "./deny-mutations.js";
|
|
14
14
|
import { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
15
15
|
import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
|
|
16
|
-
import { runPinnedCopyHelper,
|
|
16
|
+
import { runPinnedCopyHelper, runPinnedWriteWithRenamePolicy, } from "./pinned-write.js";
|
|
17
17
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
18
18
|
import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
|
|
19
19
|
import { assertNoNulPathInput, assertNoUnsafeDeviceReadPath, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
@@ -259,6 +259,7 @@ class RootHandle {
|
|
|
259
259
|
data,
|
|
260
260
|
mkdir: this.defaults.mkdir,
|
|
261
261
|
mode: this.defaults.mode,
|
|
262
|
+
renameIdentity: this.defaults.renameIdentity,
|
|
262
263
|
...options,
|
|
263
264
|
denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
|
|
264
265
|
});
|
|
@@ -693,21 +694,23 @@ async function writeFileInRoot(root, params) {
|
|
|
693
694
|
async function commitPinnedWriteInRoot(root, pinned, params) {
|
|
694
695
|
let identity;
|
|
695
696
|
try {
|
|
696
|
-
identity = await
|
|
697
|
+
identity = await runPinnedWriteWithRenamePolicy({
|
|
697
698
|
rootPath: pinned.rootReal,
|
|
698
699
|
relativeParentPath: pinned.relativeParentPath,
|
|
699
700
|
basename: pinned.basename,
|
|
701
|
+
targetPath: pinned.targetPath,
|
|
702
|
+
renameIdentity: params.renameIdentity,
|
|
700
703
|
mkdir: params.mkdir !== false,
|
|
701
704
|
mode: params.mode ?? pinned.mode,
|
|
702
705
|
overwrite: params.overwrite,
|
|
703
|
-
input: {
|
|
704
|
-
kind: "buffer",
|
|
705
|
-
data: params.data,
|
|
706
|
-
encoding: params.encoding,
|
|
707
|
-
},
|
|
706
|
+
input: { kind: "buffer", data: params.data, encoding: params.encoding },
|
|
708
707
|
});
|
|
709
708
|
}
|
|
710
709
|
catch (error) {
|
|
710
|
+
const errorCode = error?.code;
|
|
711
|
+
if (errorCode === "file_lock_stale" || errorCode === "file_lock_timeout") {
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
711
714
|
if (params.overwrite === false && isAlreadyExistsError(error)) {
|
|
712
715
|
throw new FsSafeError("already-exists", "file already exists", {
|
|
713
716
|
cause: error instanceof Error ? error : undefined,
|
package/dist/root.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, root, type DenyMutationPolicy, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root-impl.js";
|
|
1
|
+
export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, root, type DenyMutationPolicy, type HardlinkPolicy, type RenameIdentityPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root-impl.js";
|
|
2
2
|
//# sourceMappingURL=root.d.ts.map
|
package/dist/root.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,kCAAkC,EAClC,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"root.d.ts","sourceRoot":"","sources":["../src/root.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,kCAAkC,EAClC,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC"}
|
package/dist/test-hooks.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type FsSafeTestHooks = {
|
|
|
7
7
|
beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
|
|
8
8
|
beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
|
|
9
9
|
beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
|
|
10
|
+
afterPinnedWriteFallbackRename?: (targetPath: string) => Promise<void> | void;
|
|
10
11
|
beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
|
|
11
12
|
beforeTrashMove?: (targetPath: string, destPath: string) => void;
|
|
12
13
|
};
|
package/dist/test-hooks.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3E,2BAA2B,CAAC,EAAE,CAC5B,SAAS,EAAE,OAAO,GAAG,OAAO,EAC5B,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,2BAA2B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACxE,+BAA+B,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,0BAA0B,CAAC,EAAE,CAC3B,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,EACtC,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,sBAAsB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
|
|
1
|
+
{"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3E,2BAA2B,CAAC,EAAE,CAC5B,SAAS,EAAE,OAAO,GAAG,OAAO,EAC5B,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,2BAA2B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACxE,+BAA+B,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,0BAA0B,CAAC,EAAE,CAC3B,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,EACtC,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,8BAA8B,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC9E,sBAAsB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
|
package/docs/security-model.md
CHANGED
|
@@ -90,6 +90,7 @@ The library does not advertise different security guarantees per platform — it
|
|
|
90
90
|
| Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
|
|
91
91
|
| Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
|
|
92
92
|
| Helper failures degrade fd-relative hardening | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode. Atomicity and identity checks remain, but parent-directory swaps between validation and mutation are less tightly pinned without the helper. |
|
|
93
|
+
| FUSE mounts with rename-unstable inode numbers | Some FUSE mounts (rclone is a confirmed example) do not preserve source inode identity at the rename destination. The explicit `renameIdentity: "verify-content-with-lock"` compatibility mode verifies content under a cooperative lock for that boundary only; subsequent path identity checks and the default remain strict. See [Writing](writing.md) for the weaker opt-in contract. |
|
|
93
94
|
|
|
94
95
|
## Recommended deployment shape
|
|
95
96
|
|
package/docs/writing.md
CHANGED
|
@@ -192,7 +192,7 @@ await fs.write("data/blob.bin", buffer, { mkdir: false }); // override
|
|
|
192
192
|
| `not-found` | Parent does not exist and `mkdir` is false. |
|
|
193
193
|
| `not-empty` | `remove()` on a non-empty directory. |
|
|
194
194
|
| `not-removable` | `remove()` could not unlink/rmdir (typically permissions or device busy). |
|
|
195
|
-
| `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer. |
|
|
195
|
+
| `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer, or a FUSE mount with unstable inode numbers — see `renameIdentity` below. |
|
|
196
196
|
| `too-large` | `copyIn()` source exceeded `maxBytes`. |
|
|
197
197
|
| `symlink` | A path component is a symlink and policy is `reject`. |
|
|
198
198
|
| `hardlink` | `sourceHardlinks: "reject"` saw `nlink > 1`. |
|
|
@@ -232,6 +232,26 @@ try {
|
|
|
232
232
|
await fs.append(today, line);
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
+
## FUSE mounts and unstable inode numbers
|
|
236
|
+
|
|
237
|
+
Some FUSE mounts — rclone is a confirmed example — assign the destination a different inode number from the source temp file as a result of rename, even within a single process with zero concurrency. Repeated stats of an unchanged destination remain stable, but the source-to-destination `(dev, ino)` comparison always fails with `path-mismatch`.
|
|
238
|
+
|
|
239
|
+
Set `renameIdentity: "verify-content-with-lock"` on the root (or per call) to use a SHA-256 content comparison under a cooperative sidecar lock instead:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const fs = await root("/mnt/rclone-workspace", {
|
|
243
|
+
renameIdentity: "verify-content-with-lock",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await fs.write("state.json", body); // succeeds on rclone FUSE
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**How it works.** The full write runs under an exclusive per-target lock named `.fs-safe-write-<sha256>.lock` in the root. Keeping the lock in the already-canonical root avoids creating an unguarded lock path through a missing or raced target parent. The guarded Node fallback accepts the source-temp-to-destination inode mismatch only when the SHA-256 of the re-read bytes matches the SHA-256 of the bytes written. Subsequent path identity checks remain strict, so this mode requires an unchanged destination path to report stable identity. It deliberately bypasses the stricter fd-relative Python helper because that helper requires rename to preserve inode identity. The lock is released before the call returns.
|
|
250
|
+
|
|
251
|
+
**Security note.** `verify-content-with-lock` proves that the bytes observed after rename match the requested write and prevents *cooperating* writers from interleaving. It does **not** prove that the destination still names the temp-file object, retain the Python helper's fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.
|
|
252
|
+
|
|
253
|
+
Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Remove a stale sidecar only after proving that its holder can no longer write, using the application-owned recovery guidance in [File lock](sidecar-lock.md#stale-recovery-remove-if-unchanged).
|
|
254
|
+
|
|
235
255
|
## See also
|
|
236
256
|
|
|
237
257
|
- [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` and friends.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -99,13 +99,14 @@
|
|
|
99
99
|
},
|
|
100
100
|
"optionalDependencies": {
|
|
101
101
|
"jszip": "^3.10.1",
|
|
102
|
-
"tar": "7.5.
|
|
102
|
+
"tar": "7.5.19"
|
|
103
103
|
},
|
|
104
104
|
"devDependencies": {
|
|
105
|
-
"@types/node": "^22.
|
|
106
|
-
"@vitest/coverage-v8": "4.1.
|
|
105
|
+
"@types/node": "^22.20.0",
|
|
106
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
107
107
|
"typescript": "^5.9.3",
|
|
108
|
-
"
|
|
108
|
+
"vite": "7.3.5",
|
|
109
|
+
"vitest": "^4.1.9"
|
|
109
110
|
},
|
|
110
111
|
"engines": {
|
|
111
112
|
"node": ">=22"
|