@openclaw/fs-safe 0.2.5 → 0.2.6
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/README.md +6 -9
- package/dist/file-store-boundary.js +2 -2
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +6 -6
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +3 -0
- package/dist/pinned-python.d.ts.map +1 -1
- package/dist/pinned-python.js +45 -38
- package/dist/pinned-write.d.ts +3 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +54 -45
- package/dist/root-path.js +2 -2
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +76 -50
- package/dist/temp-target.js +1 -1
- package/docs/python-helper.md +3 -3
- package/docs/root.md +5 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.6 - 2026-05-17
|
|
4
|
+
|
|
5
|
+
### Security and Correctness
|
|
6
|
+
|
|
7
|
+
- Harden DeepSec-reported temp path handling, private secret writes, pinned helper commits, fallback mkdir writes, and dot-prefixed root relative paths against symlink, race, and relative-path edge cases.
|
|
8
|
+
|
|
3
9
|
## 0.2.5 - 2026-05-16
|
|
4
10
|
|
|
5
11
|
### Security and Correctness
|
package/README.md
CHANGED
|
@@ -66,18 +66,15 @@ before first use when you need a strict environment policy:
|
|
|
66
66
|
import { configureFsSafePython } from "@openclaw/fs-safe";
|
|
67
67
|
|
|
68
68
|
configureFsSafePython({ mode: "auto" }); // default: use helper, fall back if unavailable
|
|
69
|
-
configureFsSafePython({ mode: "off" }); // never spawn Python;
|
|
69
|
+
configureFsSafePython({ mode: "off" }); // never spawn Python; writes that need it fail closed
|
|
70
70
|
configureFsSafePython({ mode: "require" }); // fail closed if helper cannot start
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
Equivalent env vars: `FS_SAFE_PYTHON_MODE=auto|off|require` and
|
|
74
|
-
`FS_SAFE_PYTHON=/path/to/python3`.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
directories between validation and mutation. Windows already uses the Node
|
|
79
|
-
fallback path. See the [Python helper policy](docs/python-helper.md) for
|
|
80
|
-
deployment guidance.
|
|
74
|
+
`FS_SAFE_PYTHON=/path/to/python3`. On POSIX, disabling or losing the helper now
|
|
75
|
+
fails closed for root write paths that require fd-relative parent commits.
|
|
76
|
+
Windows already uses the Node fallback path. See the
|
|
77
|
+
[Python helper policy](docs/python-helper.md) for deployment guidance.
|
|
81
78
|
|
|
82
79
|
## Quick start
|
|
83
80
|
|
|
@@ -435,7 +432,7 @@ Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-faile
|
|
|
435
432
|
- root-bounded APIs resolve paths against a configured root and reject canonical escapes
|
|
436
433
|
- reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
|
|
437
434
|
- writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
|
|
438
|
-
- `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX
|
|
435
|
+
- `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX; security-sensitive writes fail closed when that helper is disabled or unavailable
|
|
439
436
|
- archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
|
|
440
437
|
|
|
441
438
|
## Limitations
|
|
@@ -5,7 +5,7 @@ import { Transform } from "node:stream";
|
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { assertSyncDirectoryGuard as assertDirectoryGuardSync, createSyncDirectoryGuard, } from "./directory-guard.js";
|
|
7
7
|
import { FsSafeError } from "./errors.js";
|
|
8
|
-
import { isPathInside } from "./path.js";
|
|
8
|
+
import { isPathInside, isPathRelativeEscape } from "./path.js";
|
|
9
9
|
import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
|
|
10
10
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
11
11
|
function parentRelativePath(relativePath) {
|
|
@@ -131,7 +131,7 @@ export function ensureParentSync(params) {
|
|
|
131
131
|
const rootDir = path.resolve(params.rootDir);
|
|
132
132
|
const dir = path.dirname(path.resolve(params.filePath));
|
|
133
133
|
const relative = path.relative(rootDir, dir);
|
|
134
|
-
if (
|
|
134
|
+
if (isPathRelativeEscape(relative)) {
|
|
135
135
|
throw new FsSafeError("outside-workspace", "file path escapes store root");
|
|
136
136
|
}
|
|
137
137
|
syncFs.mkdirSync(rootDir, { recursive: true, mode: params.mode });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guarded-mkdir.d.ts","sourceRoot":"","sources":["../src/guarded-mkdir.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"guarded-mkdir.d.ts","sourceRoot":"","sources":["../src/guarded-mkdir.ts"],"names":[],"mappings":"AAWA,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACnE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkChB"}
|
package/dist/guarded-mkdir.js
CHANGED
|
@@ -2,17 +2,17 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
|
+
import { isPathRelativeEscape } from "./path.js";
|
|
5
6
|
function isSameOrChildPath(candidate, parent) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function isPathEscape(relativePath) {
|
|
9
|
-
return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
7
|
+
const parentPrefix = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
|
|
8
|
+
return candidate === parent || candidate.startsWith(parentPrefix);
|
|
10
9
|
}
|
|
11
10
|
export async function mkdirPathComponentsWithGuards(params) {
|
|
12
11
|
const root = path.resolve(params.rootReal);
|
|
12
|
+
const rootCanonical = path.resolve(await fs.realpath(root));
|
|
13
13
|
const target = path.resolve(params.targetPath);
|
|
14
14
|
const relative = path.relative(root, target);
|
|
15
|
-
if (
|
|
15
|
+
if (isPathRelativeEscape(relative)) {
|
|
16
16
|
throw new FsSafeError("outside-workspace", "directory is outside workspace root");
|
|
17
17
|
}
|
|
18
18
|
let current = root;
|
|
@@ -35,7 +35,7 @@ export async function mkdirPathComponentsWithGuards(params) {
|
|
|
35
35
|
}
|
|
36
36
|
// Node's recursive mkdir follows symlinks in missing components. Build one
|
|
37
37
|
// segment at a time and realpath-check each segment before descending.
|
|
38
|
-
if (!isSameOrChildPath(path.resolve(await fs.realpath(next)),
|
|
38
|
+
if (!isSameOrChildPath(path.resolve(await fs.realpath(next)), rootCanonical)) {
|
|
39
39
|
throw new FsSafeError("outside-workspace", "directory escaped workspace root");
|
|
40
40
|
}
|
|
41
41
|
await createAsyncDirectoryGuard(next);
|
package/dist/path.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare function assertNoNulPathInput(filePath: string, message?: string)
|
|
|
6
6
|
export declare function isNotFoundPathError(value: unknown): boolean;
|
|
7
7
|
export declare function isSymlinkOpenError(value: unknown): boolean;
|
|
8
8
|
export declare function isPathInside(root: string, target: string): boolean;
|
|
9
|
+
export declare function isPathRelativeEscape(relativePath: string): boolean;
|
|
9
10
|
export declare function resolveSafeBaseDir(rootDir: string): string;
|
|
10
11
|
export declare function isWithinDir(rootDir: string, targetPath: string): boolean;
|
|
11
12
|
export declare function safeRealpathSync(targetPath: string, cache?: Map<string, string>): string | null;
|
package/dist/path.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAUzB,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
|
|
1
|
+
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAUzB,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,cAAc,CAI1E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAA6B,GAAG,IAAI,CAIjG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BlE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAElE;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAa/F;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,IAAI,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChE,OAAO,CAUT;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK,GAAG,IAAI,CAMhE;AAED,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CAuBpE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAOrF"}
|
package/dist/path.js
CHANGED
|
@@ -55,6 +55,9 @@ export function isPathInside(root, target) {
|
|
|
55
55
|
const firstSegment = relative.split(path.posix.sep)[0];
|
|
56
56
|
return relative === "" || (firstSegment !== ".." && !path.isAbsolute(relative));
|
|
57
57
|
}
|
|
58
|
+
export function isPathRelativeEscape(relativePath) {
|
|
59
|
+
return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
60
|
+
}
|
|
58
61
|
export function resolveSafeBaseDir(rootDir) {
|
|
59
62
|
const resolved = path.resolve(rootDir);
|
|
60
63
|
return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AA2ZA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AAiOD,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEjE"}
|
package/dist/pinned-python.js
CHANGED
|
@@ -3,26 +3,20 @@ import fsSync from "node:fs";
|
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
5
5
|
const PINNED_PYTHON_WORKER_SOURCE = String.raw `
|
|
6
|
-
import base64
|
|
7
|
-
import errno
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import secrets
|
|
11
|
-
import stat
|
|
12
|
-
import sys
|
|
13
|
-
|
|
6
|
+
import base64, errno, json, os, secrets, stat, sys
|
|
14
7
|
DIR_FLAGS = os.O_RDONLY
|
|
15
8
|
if hasattr(os, "O_DIRECTORY"):
|
|
16
9
|
DIR_FLAGS |= os.O_DIRECTORY
|
|
17
10
|
if hasattr(os, "O_NOFOLLOW"):
|
|
18
11
|
DIR_FLAGS |= os.O_NOFOLLOW
|
|
19
12
|
READ_FLAGS = os.O_RDONLY
|
|
13
|
+
if hasattr(os, "O_NONBLOCK"):
|
|
14
|
+
READ_FLAGS |= os.O_NONBLOCK
|
|
20
15
|
if hasattr(os, "O_NOFOLLOW"):
|
|
21
16
|
READ_FLAGS |= os.O_NOFOLLOW
|
|
22
17
|
WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
23
18
|
if hasattr(os, "O_NOFOLLOW"):
|
|
24
19
|
WRITE_FLAGS |= os.O_NOFOLLOW
|
|
25
|
-
|
|
26
20
|
def split_relative(value):
|
|
27
21
|
if value in ("", "."):
|
|
28
22
|
return []
|
|
@@ -35,10 +29,8 @@ def split_relative(value):
|
|
|
35
29
|
if part == "..":
|
|
36
30
|
raise OSError(errno.EPERM, "path traversal is not allowed")
|
|
37
31
|
return parts
|
|
38
|
-
|
|
39
32
|
def open_dir(path_value, dir_fd=None):
|
|
40
33
|
return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)
|
|
41
|
-
|
|
42
34
|
def walk_dir(root_fd, segments, mkdir_enabled=False):
|
|
43
35
|
current_fd = os.dup(root_fd)
|
|
44
36
|
try:
|
|
@@ -56,14 +48,12 @@ def walk_dir(root_fd, segments, mkdir_enabled=False):
|
|
|
56
48
|
except Exception:
|
|
57
49
|
os.close(current_fd)
|
|
58
50
|
raise
|
|
59
|
-
|
|
60
51
|
def parent_and_basename(root_fd, relative):
|
|
61
52
|
segments = split_relative(relative)
|
|
62
53
|
if not segments:
|
|
63
54
|
raise OSError(errno.EPERM, "operation requires a non-root path")
|
|
64
55
|
parent_fd = walk_dir(root_fd, segments[:-1])
|
|
65
56
|
return parent_fd, segments[-1]
|
|
66
|
-
|
|
67
57
|
def encode_stat(st):
|
|
68
58
|
mode = st.st_mode
|
|
69
59
|
return {
|
|
@@ -79,14 +69,12 @@ def encode_stat(st):
|
|
|
79
69
|
"size": st.st_size,
|
|
80
70
|
"uid": st.st_uid,
|
|
81
71
|
}
|
|
82
|
-
|
|
83
72
|
def reject_unsafe_endpoint(st):
|
|
84
73
|
mode = st.st_mode
|
|
85
74
|
if stat.S_ISLNK(mode):
|
|
86
75
|
raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
|
|
87
76
|
if stat.S_ISREG(mode) and st.st_nlink > 1:
|
|
88
77
|
raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
|
|
89
|
-
|
|
90
78
|
def copy_bytes(source_fd, dest_fd):
|
|
91
79
|
while True:
|
|
92
80
|
chunk = os.read(source_fd, 65536)
|
|
@@ -98,7 +86,6 @@ def copy_bytes(source_fd, dest_fd):
|
|
|
98
86
|
if written <= 0:
|
|
99
87
|
raise OSError(errno.EIO, "short write")
|
|
100
88
|
view = view[written:]
|
|
101
|
-
|
|
102
89
|
def write_all(fd, data):
|
|
103
90
|
view = memoryview(data)
|
|
104
91
|
while view:
|
|
@@ -106,11 +93,9 @@ def write_all(fd, data):
|
|
|
106
93
|
if written <= 0:
|
|
107
94
|
raise OSError(errno.EIO, "short write")
|
|
108
95
|
view = view[written:]
|
|
109
|
-
|
|
110
96
|
def link_unsupported(exc):
|
|
111
97
|
unsupported = (errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP))
|
|
112
98
|
return getattr(exc, "errno", None) in unsupported
|
|
113
|
-
|
|
114
99
|
def link_no_replace(name, new_name, source_fd, target_fd):
|
|
115
100
|
linked = False
|
|
116
101
|
try:
|
|
@@ -125,11 +110,9 @@ def link_no_replace(name, new_name, source_fd, target_fd):
|
|
|
125
110
|
os.fsync(source_fd)
|
|
126
111
|
if source_fd != target_fd:
|
|
127
112
|
os.fsync(target_fd)
|
|
128
|
-
|
|
129
113
|
def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basename, mode, expected=None, unlink_source=False):
|
|
130
114
|
source_fd = os.open(source_name, READ_FLAGS, dir_fd=source_parent_fd)
|
|
131
|
-
dest_fd = None
|
|
132
|
-
success = False
|
|
115
|
+
dest_fd = None; success = False; dest_stat = None
|
|
133
116
|
try:
|
|
134
117
|
if expected is not None:
|
|
135
118
|
source_stat = os.fstat(source_fd)
|
|
@@ -138,6 +121,7 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
|
|
|
138
121
|
dest_fd = os.open(basename, WRITE_FLAGS, mode, dir_fd=target_parent_fd)
|
|
139
122
|
copy_bytes(source_fd, dest_fd)
|
|
140
123
|
os.fsync(dest_fd)
|
|
124
|
+
dest_stat = os.fstat(dest_fd)
|
|
141
125
|
success = True
|
|
142
126
|
finally:
|
|
143
127
|
os.close(source_fd)
|
|
@@ -153,19 +137,40 @@ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basena
|
|
|
153
137
|
try: os.unlink(basename, dir_fd=target_parent_fd)
|
|
154
138
|
except FileNotFoundError: pass
|
|
155
139
|
raise
|
|
156
|
-
|
|
157
|
-
def
|
|
140
|
+
return dest_stat
|
|
141
|
+
def same_identity(left, right):
|
|
142
|
+
return left.st_dev == right.st_dev and left.st_ino == right.st_ino
|
|
143
|
+
def verify_temp_name(parent_fd, temp_name, expected_stat):
|
|
144
|
+
current_stat = os.lstat(temp_name, dir_fd=parent_fd)
|
|
145
|
+
if stat.S_ISLNK(current_stat.st_mode) or not same_identity(current_stat, expected_stat):
|
|
146
|
+
raise RuntimeError("fs-safe-temp-mismatch")
|
|
147
|
+
def verify_committed_temp(parent_fd, basename, expected_stat):
|
|
148
|
+
final_stat = os.lstat(basename, dir_fd=parent_fd)
|
|
149
|
+
if not stat.S_ISLNK(final_stat.st_mode) and same_identity(final_stat, expected_stat):
|
|
150
|
+
return final_stat
|
|
151
|
+
try: os.unlink(basename, dir_fd=parent_fd)
|
|
152
|
+
except FileNotFoundError: pass
|
|
153
|
+
raise RuntimeError("fs-safe-temp-mismatch")
|
|
154
|
+
def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, expected_stat):
|
|
155
|
+
verify_temp_name(parent_fd, temp_name, expected_stat)
|
|
158
156
|
if overwrite:
|
|
159
157
|
os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
158
|
+
return verify_committed_temp(parent_fd, basename, expected_stat)
|
|
160
159
|
else:
|
|
161
160
|
try:
|
|
162
161
|
os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)
|
|
162
|
+
final_stat = verify_committed_temp(parent_fd, basename, expected_stat)
|
|
163
163
|
os.unlink(temp_name, dir_fd=parent_fd)
|
|
164
|
+
return final_stat
|
|
164
165
|
except OSError as exc:
|
|
165
166
|
if not link_unsupported(exc):
|
|
166
167
|
raise
|
|
167
|
-
copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode,
|
|
168
|
-
|
|
168
|
+
return copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, expected_stat, True)
|
|
169
|
+
def assert_expected_root(root_fd, payload):
|
|
170
|
+
if "rootDev" in payload or "rootIno" in payload:
|
|
171
|
+
root_stat = os.fstat(root_fd)
|
|
172
|
+
if root_stat.st_dev != int(payload["rootDev"]) or root_stat.st_ino != int(payload["rootIno"]):
|
|
173
|
+
raise RuntimeError("fs-safe-root-mismatch")
|
|
169
174
|
def stat_path(root_fd, payload):
|
|
170
175
|
relative = payload.get("relativePath", "")
|
|
171
176
|
segments = split_relative(relative)
|
|
@@ -179,7 +184,6 @@ def stat_path(root_fd, payload):
|
|
|
179
184
|
return encode_stat(st)
|
|
180
185
|
finally:
|
|
181
186
|
os.close(parent_fd)
|
|
182
|
-
|
|
183
187
|
def readdir_path(root_fd, payload):
|
|
184
188
|
dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")))
|
|
185
189
|
try:
|
|
@@ -195,12 +199,9 @@ def readdir_path(root_fd, payload):
|
|
|
195
199
|
return entries
|
|
196
200
|
finally:
|
|
197
201
|
os.close(dir_fd)
|
|
198
|
-
|
|
199
202
|
def mkdirp_path(root_fd, payload):
|
|
200
203
|
dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True)
|
|
201
|
-
os.close(dir_fd)
|
|
202
|
-
return None
|
|
203
|
-
|
|
204
|
+
os.close(dir_fd); return None
|
|
204
205
|
def remove_tree(parent_fd, basename):
|
|
205
206
|
st = os.lstat(basename, dir_fd=parent_fd)
|
|
206
207
|
if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
|
|
@@ -213,7 +214,6 @@ def remove_tree(parent_fd, basename):
|
|
|
213
214
|
os.rmdir(basename, dir_fd=parent_fd)
|
|
214
215
|
else:
|
|
215
216
|
os.unlink(basename, dir_fd=parent_fd)
|
|
216
|
-
|
|
217
217
|
def remove_path(root_fd, payload):
|
|
218
218
|
parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", ""))
|
|
219
219
|
try:
|
|
@@ -296,14 +296,15 @@ def write_path(root_fd, payload):
|
|
|
296
296
|
except FileNotFoundError:
|
|
297
297
|
pass
|
|
298
298
|
temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
|
|
299
|
+
os.fchmod(temp_fd, mode)
|
|
299
300
|
write_all(temp_fd, data)
|
|
300
301
|
os.fsync(temp_fd)
|
|
302
|
+
temp_stat = os.fstat(temp_fd)
|
|
301
303
|
os.close(temp_fd)
|
|
302
304
|
temp_fd = None
|
|
303
|
-
commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
|
|
305
|
+
result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
|
|
304
306
|
temp_name = None
|
|
305
307
|
os.fsync(parent_fd)
|
|
306
|
-
result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
|
|
307
308
|
return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
|
|
308
309
|
finally:
|
|
309
310
|
if temp_fd is not None:
|
|
@@ -334,6 +335,7 @@ def copy_path(root_fd, payload):
|
|
|
334
335
|
raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
|
|
335
336
|
parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
|
|
336
337
|
temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
|
|
338
|
+
os.fchmod(temp_fd, mode)
|
|
337
339
|
written_bytes = 0
|
|
338
340
|
while True:
|
|
339
341
|
chunk = os.read(source_fd, 65536)
|
|
@@ -349,12 +351,12 @@ def copy_path(root_fd, payload):
|
|
|
349
351
|
raise OSError(errno.EIO, "short write")
|
|
350
352
|
view = view[written:]
|
|
351
353
|
os.fsync(temp_fd)
|
|
354
|
+
temp_stat = os.fstat(temp_fd)
|
|
352
355
|
os.close(temp_fd)
|
|
353
356
|
temp_fd = None
|
|
354
|
-
commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
|
|
357
|
+
result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
|
|
355
358
|
temp_name = None
|
|
356
359
|
os.fsync(parent_fd)
|
|
357
|
-
result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
|
|
358
360
|
return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
|
|
359
361
|
finally:
|
|
360
362
|
os.close(source_fd)
|
|
@@ -371,6 +373,7 @@ def copy_path(root_fd, payload):
|
|
|
371
373
|
def run_operation(operation, root_path, payload):
|
|
372
374
|
root_fd = open_dir(root_path)
|
|
373
375
|
try:
|
|
376
|
+
assert_expected_root(root_fd, payload)
|
|
374
377
|
if operation == "stat":
|
|
375
378
|
return stat_path(root_fd, payload)
|
|
376
379
|
if operation == "readdir":
|
|
@@ -475,6 +478,12 @@ function mapWorkerError(response) {
|
|
|
475
478
|
if (message.includes("fs-safe-source-mismatch")) {
|
|
476
479
|
return new FsSafeError("path-mismatch", "source path changed during copy");
|
|
477
480
|
}
|
|
481
|
+
if (message.includes("fs-safe-temp-mismatch")) {
|
|
482
|
+
return new FsSafeError("path-mismatch", "temp path changed during write");
|
|
483
|
+
}
|
|
484
|
+
if (message.includes("fs-safe-root-mismatch")) {
|
|
485
|
+
return new FsSafeError("path-mismatch", "root path changed during operation");
|
|
486
|
+
}
|
|
478
487
|
if (message.includes("fs-safe-directory-noreplace-unsupported")) {
|
|
479
488
|
return new FsSafeError("invalid-path", "directory moves require overwrite: true");
|
|
480
489
|
}
|
|
@@ -655,9 +664,7 @@ export function validatePinnedOperationPayload(payload) {
|
|
|
655
664
|
}
|
|
656
665
|
}
|
|
657
666
|
export function isPinnedHelperUnavailable(error) {
|
|
658
|
-
return error instanceof Error &&
|
|
659
|
-
"code" in error &&
|
|
660
|
-
error.code === "helper-unavailable";
|
|
667
|
+
return error instanceof Error && "code" in error && error.code === "helper-unavailable";
|
|
661
668
|
}
|
|
662
669
|
function validatePinnedRelativePath(relativePath) {
|
|
663
670
|
if (relativePath.length === 0 || relativePath === ".") {
|
package/dist/pinned-write.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
2
|
import type { FileIdentityStat } from "./file-identity.js";
|
|
3
3
|
type PinnedWriteInput = {
|
|
4
4
|
kind: "buffer";
|
|
@@ -17,6 +17,7 @@ export declare function runPinnedWriteHelper(params: {
|
|
|
17
17
|
overwrite?: boolean;
|
|
18
18
|
maxBytes?: number;
|
|
19
19
|
input: PinnedWriteInput;
|
|
20
|
+
rootIdentity?: FileIdentityStat;
|
|
20
21
|
}): Promise<FileIdentityStat>;
|
|
21
22
|
export declare function runPinnedCopyHelper(params: {
|
|
22
23
|
rootPath: string;
|
|
@@ -28,6 +29,7 @@ export declare function runPinnedCopyHelper(params: {
|
|
|
28
29
|
maxBytes?: number;
|
|
29
30
|
sourcePath: string;
|
|
30
31
|
sourceIdentity: FileIdentityStat;
|
|
32
|
+
rootIdentity?: FileIdentityStat;
|
|
31
33
|
}): Promise<FileIdentityStat>;
|
|
32
34
|
export {};
|
|
33
35
|
//# sourceMappingURL=pinned-write.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"
|
|
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;AAG5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAW3D,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,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,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;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAwC5B;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
|
@@ -2,11 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fsSync from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import { pipeline } from "node:stream/promises";
|
|
7
|
-
import { createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
5
|
+
import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
8
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
9
8
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
9
|
+
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
10
10
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
11
11
|
import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
|
|
12
12
|
function byteLength(input, encoding) {
|
|
@@ -28,29 +28,21 @@ function assertWithinMaxBytes(bytes, maxBytes) {
|
|
|
28
28
|
throw new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
function
|
|
32
|
-
if (maxBytes === undefined) {
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
31
|
+
async function writeStreamToHandle(stream, handle, maxBytes) {
|
|
35
32
|
let bytes = 0;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
for await (const chunk of stream) {
|
|
34
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
35
|
+
bytes += buffer.byteLength;
|
|
36
|
+
assertWithinMaxBytes(bytes, maxBytes);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
while (offset < buffer.byteLength) {
|
|
39
|
+
const { bytesWritten } = await handle.write(buffer, offset, buffer.byteLength - offset);
|
|
40
|
+
if (bytesWritten <= 0) {
|
|
41
|
+
throw new FsSafeError("helper-failed", "fallback stream write made no progress");
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
async function pipelineWithMaxBytes(stream, destination, maxBytes) {
|
|
48
|
-
const limiter = createMaxBytesTransform(maxBytes);
|
|
49
|
-
if (limiter) {
|
|
50
|
-
await pipeline(stream, limiter, destination);
|
|
51
|
-
return;
|
|
43
|
+
offset += bytesWritten;
|
|
44
|
+
}
|
|
52
45
|
}
|
|
53
|
-
await pipeline(stream, destination);
|
|
54
46
|
}
|
|
55
47
|
async function inputToBase64(input, maxBytes) {
|
|
56
48
|
if (input.kind === "buffer") {
|
|
@@ -75,7 +67,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
75
67
|
relativeParentPath: params.relativeParentPath,
|
|
76
68
|
});
|
|
77
69
|
if (getFsSafePythonConfig().mode === "off") {
|
|
78
|
-
return await
|
|
70
|
+
return await runPinnedWriteFallbackOrThrow(params);
|
|
79
71
|
}
|
|
80
72
|
if (params.input.kind === "stream") {
|
|
81
73
|
try {
|
|
@@ -83,7 +75,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
83
75
|
}
|
|
84
76
|
catch (error) {
|
|
85
77
|
if (canFallbackFromPythonError(error)) {
|
|
86
|
-
return await
|
|
78
|
+
return await runPinnedWriteFallbackOrThrow(params, error);
|
|
87
79
|
}
|
|
88
80
|
throw error;
|
|
89
81
|
}
|
|
@@ -96,6 +88,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
96
88
|
mode: params.mode || 0o600,
|
|
97
89
|
overwrite: params.overwrite !== false,
|
|
98
90
|
relativeParentPath: params.relativeParentPath,
|
|
91
|
+
...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
|
|
99
92
|
};
|
|
100
93
|
try {
|
|
101
94
|
return await runPinnedPythonOperation({
|
|
@@ -106,7 +99,7 @@ export async function runPinnedWriteHelper(params) {
|
|
|
106
99
|
}
|
|
107
100
|
catch (error) {
|
|
108
101
|
if (canFallbackFromPythonError(error)) {
|
|
109
|
-
return await
|
|
102
|
+
return await runPinnedWriteFallbackOrThrow(params, error);
|
|
110
103
|
}
|
|
111
104
|
throw error;
|
|
112
105
|
}
|
|
@@ -126,22 +119,29 @@ export async function runPinnedCopyHelper(params) {
|
|
|
126
119
|
mode: params.mode || 0o600,
|
|
127
120
|
overwrite: params.overwrite !== false,
|
|
128
121
|
relativeParentPath: params.relativeParentPath,
|
|
122
|
+
...(params.rootIdentity ? { rootDev: params.rootIdentity.dev, rootIno: params.rootIdentity.ino } : {}),
|
|
129
123
|
sourceDev: params.sourceIdentity.dev,
|
|
130
124
|
sourceIno: params.sourceIdentity.ino,
|
|
131
125
|
sourcePath: params.sourcePath,
|
|
132
126
|
},
|
|
133
127
|
});
|
|
134
128
|
}
|
|
129
|
+
async function runPinnedWriteFallbackOrThrow(params, cause) {
|
|
130
|
+
if (process.platform !== "win32") {
|
|
131
|
+
throw new FsSafeError("helper-unavailable", "Python helper is required for pinned writes on this platform", { cause });
|
|
132
|
+
}
|
|
133
|
+
return await runPinnedWriteFallback(params);
|
|
134
|
+
}
|
|
135
135
|
async function runPinnedWriteFallback(params) {
|
|
136
136
|
const parentPath = params.relativeParentPath
|
|
137
137
|
? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
|
|
138
138
|
: params.rootPath;
|
|
139
|
-
const parentGuard = await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
|
|
140
139
|
if (params.mkdir) {
|
|
141
|
-
await
|
|
142
|
-
await fs.mkdir(parentPath, { recursive: true });
|
|
143
|
-
});
|
|
140
|
+
await mkdirPathComponentsWithGuards({ rootReal: params.rootPath, targetPath: parentPath });
|
|
144
141
|
}
|
|
142
|
+
const parentGuard = params.mkdir
|
|
143
|
+
? await createAsyncDirectoryGuard(parentPath)
|
|
144
|
+
: await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
|
|
145
145
|
const targetPath = path.join(parentPath, params.basename);
|
|
146
146
|
if (params.overwrite === false) {
|
|
147
147
|
let handle = await withAsyncDirectoryGuards([parentGuard], async () => await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode), {
|
|
@@ -163,7 +163,7 @@ async function runPinnedWriteFallback(params) {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
else {
|
|
166
|
-
await
|
|
166
|
+
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
|
|
167
167
|
}
|
|
168
168
|
const stat = await handle.stat();
|
|
169
169
|
created = false;
|
|
@@ -184,7 +184,9 @@ async function runPinnedWriteFallback(params) {
|
|
|
184
184
|
? fsSync.constants.O_NOFOLLOW
|
|
185
185
|
: 0);
|
|
186
186
|
let handle;
|
|
187
|
-
let
|
|
187
|
+
let tempStat;
|
|
188
|
+
let targetStat;
|
|
189
|
+
let renamed = false;
|
|
188
190
|
try {
|
|
189
191
|
handle = await fs.open(tempPath, tempFlags, params.mode);
|
|
190
192
|
if (params.input.kind === "buffer") {
|
|
@@ -197,27 +199,34 @@ async function runPinnedWriteFallback(params) {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
else {
|
|
200
|
-
|
|
201
|
-
writable.once("close", () => {
|
|
202
|
-
handleClosedByStream = true;
|
|
203
|
-
});
|
|
204
|
-
await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
|
|
202
|
+
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
|
|
205
203
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
tempStat = await handle.stat();
|
|
205
|
+
const tempPathStat = await fs.lstat(tempPath);
|
|
206
|
+
if (tempPathStat.isSymbolicLink() || !sameFileIdentity(tempPathStat, tempStat)) {
|
|
207
|
+
throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
|
|
209
208
|
}
|
|
209
|
+
const expectedTempStat = tempStat;
|
|
210
|
+
await handle.close().catch(() => undefined);
|
|
211
|
+
handle = undefined;
|
|
210
212
|
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
211
213
|
await fs.rename(tempPath, targetPath);
|
|
214
|
+
renamed = true;
|
|
215
|
+
targetStat = await fs.lstat(targetPath);
|
|
216
|
+
if (targetStat.isSymbolicLink() || !sameFileIdentity(targetStat, expectedTempStat)) {
|
|
217
|
+
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
218
|
+
}
|
|
212
219
|
});
|
|
213
220
|
}
|
|
214
221
|
catch (error) {
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
await handle?.close().catch(() => undefined);
|
|
223
|
+
if (!renamed) {
|
|
224
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
217
225
|
}
|
|
218
|
-
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
219
226
|
throw error;
|
|
220
227
|
}
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
if (!targetStat) {
|
|
229
|
+
throw new FsSafeError("path-mismatch", "fallback target was not verified");
|
|
230
|
+
}
|
|
231
|
+
return { dev: targetStat.dev, ino: targetStat.ino };
|
|
223
232
|
}
|
package/dist/root-path.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { isNotFoundPathError, isPathInside } from "./path.js";
|
|
5
|
+
import { isNotFoundPathError, isPathInside, isPathRelativeEscape } from "./path.js";
|
|
6
6
|
export const ROOT_PATH_ALIAS_POLICIES = {
|
|
7
7
|
strict: Object.freeze({
|
|
8
8
|
allowFinalSymlinkForUnlink: false,
|
|
@@ -531,7 +531,7 @@ function relativeInsideRoot(rootPath, targetPath) {
|
|
|
531
531
|
if (!relative || relative === ".") {
|
|
532
532
|
return "";
|
|
533
533
|
}
|
|
534
|
-
if (
|
|
534
|
+
if (isPathRelativeEscape(relative)) {
|
|
535
535
|
return "";
|
|
536
536
|
}
|
|
537
537
|
return relative;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAqHF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;
|
|
1
|
+
{"version":3,"file":"secret-file.d.ts","sourceRoot":"","sources":["../src/secret-file.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,6BAA6B,QAAY,CAAC;AACvD,eAAO,MAAM,uBAAuB,MAAQ,CAAC;AAC7C,eAAO,MAAM,wBAAwB,MAAQ,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAqHF,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAQR;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,qBAA0B,GAClC,MAAM,GAAG,SAAS,CAMpB;AAoID,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+ChB"}
|
package/dist/secret-file.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import fs from "node:fs";
|
|
3
2
|
import fsp from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
5
|
-
import { createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
4
|
+
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
6
5
|
import { FsSafeError } from "./errors.js";
|
|
7
|
-
import {
|
|
6
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
8
7
|
import { resolveHomeRelativePath } from "./home-dir.js";
|
|
9
8
|
import { openPinnedFileSync } from "./pinned-open.js";
|
|
9
|
+
import { runPinnedWriteHelper } from "./pinned-write.js";
|
|
10
10
|
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
|
|
11
11
|
export const PRIVATE_SECRET_DIR_MODE = 0o700;
|
|
12
12
|
export const PRIVATE_SECRET_FILE_MODE = 0o600;
|
|
@@ -128,15 +128,18 @@ export function tryReadSecretFileSync(filePath, label, options = {}) {
|
|
|
128
128
|
const result = readSecretFileOutcomeSync(filePath, label, options);
|
|
129
129
|
return result.ok ? result.secret : undefined;
|
|
130
130
|
}
|
|
131
|
+
function isRelativeEscape(relativePath) {
|
|
132
|
+
return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
133
|
+
}
|
|
131
134
|
function assertPathWithinRoot(rootDir, targetPath) {
|
|
132
135
|
const relative = path.relative(rootDir, targetPath);
|
|
133
|
-
if (!relative ||
|
|
136
|
+
if (!relative || isRelativeEscape(relative)) {
|
|
134
137
|
throw new Error(`Private secret path must stay under ${rootDir}.`);
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
function assertRealPathWithinRoot(rootDir, targetPath) {
|
|
138
141
|
const relative = path.relative(rootDir, targetPath);
|
|
139
|
-
if (
|
|
142
|
+
if (isRelativeEscape(relative)) {
|
|
140
143
|
throw new Error(`Private secret path must stay under ${rootDir}.`);
|
|
141
144
|
}
|
|
142
145
|
}
|
|
@@ -151,30 +154,62 @@ async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {
|
|
|
151
154
|
throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
157
|
+
async function enforcePrivateFileIdentityAndMode(resolvedPath, expectedIdentity, expectedMode) {
|
|
158
|
+
const noFollowFlag = process.platform !== "win32" && "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
|
|
159
|
+
const handle = await fsp.open(resolvedPath, fs.constants.O_RDONLY | noFollowFlag);
|
|
160
|
+
try {
|
|
161
|
+
const openedStat = await handle.stat();
|
|
162
|
+
if (!openedStat.isFile() || !sameFileIdentity(openedStat, expectedIdentity)) {
|
|
163
|
+
throw new FsSafeError("path-mismatch", "private secret file changed during write");
|
|
164
|
+
}
|
|
165
|
+
const pathStat = await fsp.lstat(resolvedPath);
|
|
166
|
+
if (pathStat.isSymbolicLink() || !sameFileIdentity(pathStat, openedStat)) {
|
|
167
|
+
throw new FsSafeError("path-mismatch", "private secret path changed during write");
|
|
168
|
+
}
|
|
169
|
+
if (process.platform !== "win32") {
|
|
170
|
+
await handle.chmod(expectedMode);
|
|
171
|
+
const chmodStat = await handle.stat();
|
|
172
|
+
const actualMode = chmodStat.mode & 0o777;
|
|
173
|
+
if (actualMode !== expectedMode) {
|
|
174
|
+
throw new Error(`Private secret file ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);
|
|
175
|
+
}
|
|
176
|
+
const refreshedPathStat = await fsp.lstat(resolvedPath);
|
|
177
|
+
if (refreshedPathStat.isSymbolicLink() || !sameFileIdentity(refreshedPathStat, chmodStat)) {
|
|
178
|
+
throw new FsSafeError("path-mismatch", "private secret path changed during mode check");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await handle.close().catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
154
186
|
async function ensurePrivateDirectory(rootDir, targetDir, mode) {
|
|
155
187
|
const resolvedRoot = path.resolve(rootDir);
|
|
156
188
|
const resolvedTarget = path.resolve(targetDir);
|
|
189
|
+
await fsp.mkdir(resolvedRoot, { recursive: true, mode });
|
|
190
|
+
const rootStat = await fsp.lstat(resolvedRoot);
|
|
191
|
+
if (rootStat.isSymbolicLink()) {
|
|
192
|
+
throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
|
|
193
|
+
}
|
|
194
|
+
if (!rootStat.isDirectory()) {
|
|
195
|
+
throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
|
|
196
|
+
}
|
|
197
|
+
const rootGuard = await createAsyncDirectoryGuard(resolvedRoot);
|
|
198
|
+
await enforcePrivatePathMode(rootGuard.realPath, mode, "directory");
|
|
199
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
157
200
|
if (resolvedTarget === resolvedRoot) {
|
|
158
|
-
|
|
159
|
-
const rootStat = await fsp.lstat(resolvedRoot);
|
|
160
|
-
if (rootStat.isSymbolicLink()) {
|
|
161
|
-
throw new Error(`Private secret root ${resolvedRoot} must not be a symlink.`);
|
|
162
|
-
}
|
|
163
|
-
if (!rootStat.isDirectory()) {
|
|
164
|
-
throw new Error(`Private secret root ${resolvedRoot} must be a directory.`);
|
|
165
|
-
}
|
|
166
|
-
await enforcePrivatePathMode(resolvedRoot, mode, "directory");
|
|
167
|
-
return;
|
|
201
|
+
return { rootGuard, targetReal: rootGuard.realPath };
|
|
168
202
|
}
|
|
169
203
|
assertPathWithinRoot(resolvedRoot, resolvedTarget);
|
|
170
|
-
|
|
171
|
-
const resolvedRootReal = await fsp.realpath(resolvedRoot);
|
|
204
|
+
const resolvedRootReal = rootGuard.realPath;
|
|
172
205
|
let current = resolvedRoot;
|
|
173
206
|
for (const segment of path
|
|
174
207
|
.relative(resolvedRoot, resolvedTarget)
|
|
175
208
|
.split(path.sep)
|
|
176
209
|
.filter(Boolean)) {
|
|
177
210
|
current = path.join(current, segment);
|
|
211
|
+
const parentGuard = await createAsyncDirectoryGuard(path.dirname(current));
|
|
212
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
178
213
|
try {
|
|
179
214
|
const stat = await fsp.lstat(current);
|
|
180
215
|
if (stat.isSymbolicLink()) {
|
|
@@ -188,12 +223,16 @@ async function ensurePrivateDirectory(rootDir, targetDir, mode) {
|
|
|
188
223
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
189
224
|
throw error;
|
|
190
225
|
}
|
|
226
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
191
227
|
await fsp.mkdir(current, { mode });
|
|
192
228
|
}
|
|
193
229
|
const currentReal = await fsp.realpath(current);
|
|
194
230
|
assertRealPathWithinRoot(resolvedRootReal, currentReal);
|
|
195
231
|
await enforcePrivatePathMode(currentReal, mode, "directory");
|
|
232
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
233
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
196
234
|
}
|
|
235
|
+
return { rootGuard, targetReal: await fsp.realpath(resolvedTarget) };
|
|
197
236
|
}
|
|
198
237
|
export async function writeSecretFileAtomic(params) {
|
|
199
238
|
const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
|
|
@@ -202,13 +241,12 @@ export async function writeSecretFileAtomic(params) {
|
|
|
202
241
|
const resolvedFile = path.resolve(params.filePath);
|
|
203
242
|
assertPathWithinRoot(resolvedRoot, resolvedFile);
|
|
204
243
|
const intendedParentDir = path.dirname(resolvedFile);
|
|
205
|
-
await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const parentGuard = await createAsyncDirectoryGuard(parentDir);
|
|
244
|
+
const { rootGuard, targetReal } = await ensurePrivateDirectory(resolvedRoot, intendedParentDir, dirMode);
|
|
245
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
246
|
+
assertRealPathWithinRoot(rootGuard.realPath, targetReal);
|
|
247
|
+
const parentGuard = await createAsyncDirectoryGuard(targetReal);
|
|
210
248
|
const fileName = path.basename(resolvedFile);
|
|
211
|
-
const finalFilePath = path.join(
|
|
249
|
+
const finalFilePath = path.join(targetReal, fileName);
|
|
212
250
|
try {
|
|
213
251
|
const stat = await fsp.lstat(finalFilePath);
|
|
214
252
|
if (stat.isSymbolicLink()) {
|
|
@@ -223,31 +261,19 @@ export async function writeSecretFileAtomic(params) {
|
|
|
223
261
|
throw error;
|
|
224
262
|
}
|
|
225
263
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
243
|
-
await fsp.rename(tempPath, finalFilePath);
|
|
244
|
-
});
|
|
245
|
-
createdTemp = false;
|
|
246
|
-
await enforcePrivatePathMode(finalFilePath, mode, "file");
|
|
247
|
-
}
|
|
248
|
-
finally {
|
|
249
|
-
if (createdTemp) {
|
|
250
|
-
await fsp.unlink(tempPath).catch(() => undefined);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
264
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
265
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
266
|
+
const identity = await runPinnedWriteHelper({
|
|
267
|
+
rootPath: parentGuard.realPath,
|
|
268
|
+
relativeParentPath: "",
|
|
269
|
+
basename: fileName,
|
|
270
|
+
mkdir: false,
|
|
271
|
+
mode,
|
|
272
|
+
overwrite: true,
|
|
273
|
+
input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
|
|
274
|
+
rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
|
|
275
|
+
});
|
|
276
|
+
await assertAsyncDirectoryGuard(rootGuard);
|
|
277
|
+
await assertAsyncDirectoryGuard(parentGuard);
|
|
278
|
+
await enforcePrivateFileIdentityAndMode(finalFilePath, identity, mode);
|
|
253
279
|
}
|
package/dist/temp-target.js
CHANGED
|
@@ -52,7 +52,7 @@ async function cleanupTempDir(dir, onCleanupError) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function resolveTempRoot(rootDir) {
|
|
55
|
-
return rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" });
|
|
55
|
+
return path.resolve(rootDir ?? resolveSecureTempRoot({ fallbackPrefix: "fs-safe" }));
|
|
56
56
|
}
|
|
57
57
|
export async function tempFile(params) {
|
|
58
58
|
const rootDir = resolveTempRoot(params.rootDir);
|
package/docs/python-helper.md
CHANGED
|
@@ -62,12 +62,12 @@ Node-only mode still keeps the important application-level guardrails:
|
|
|
62
62
|
- root-relative path validation;
|
|
63
63
|
- canonical root checks;
|
|
64
64
|
- no-follow opens where Node/platform support exists;
|
|
65
|
-
- file identity checks around reads and writes;
|
|
66
|
-
- atomic sibling-temp replacement;
|
|
65
|
+
- file identity checks around reads and writes where a safe Node fallback exists;
|
|
66
|
+
- atomic sibling-temp replacement on fallback platforms;
|
|
67
67
|
- hardlink/symlink policy checks where the API requests them;
|
|
68
68
|
- byte limits and structured `FsSafeError` failures.
|
|
69
69
|
|
|
70
|
-
What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation.
|
|
70
|
+
What gets weaker is the POSIX defense against another same-UID process swapping a parent directory between validation and mutation. Write paths that need fd-relative parent commits now fail closed when the helper is disabled or unavailable; other operations such as `root().move()`, `root().remove()`, and `root().mkdir()` may still rely on Node path operations plus pre/post checks instead of parent-fd syscalls.
|
|
71
71
|
|
|
72
72
|
That is usually acceptable when the root directory is only writable by the trusted application user. It is not the right posture if untrusted local processes can race writes in the same tree and you are relying on `fs-safe` as part of the security boundary.
|
|
73
73
|
|
package/docs/root.md
CHANGED
|
@@ -102,15 +102,14 @@ operations that Node's `fs` API does not expose ergonomically.
|
|
|
102
102
|
```ts
|
|
103
103
|
import { configureFsSafePython } from "@openclaw/fs-safe/config";
|
|
104
104
|
|
|
105
|
-
configureFsSafePython({ mode: "off" }); //
|
|
105
|
+
configureFsSafePython({ mode: "off" }); // disable helper; some writes fail closed
|
|
106
106
|
configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
`auto` is the default. Configure the mode before creating roots.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
[Python helper policy](python-helper.md) for deployment guidance.
|
|
109
|
+
`auto` is the default. Configure the mode before creating roots. On POSIX,
|
|
110
|
+
write methods that require fd-relative parent commits fail closed without the
|
|
111
|
+
helper. Use `require` when any helper loss should be treated as a deployment
|
|
112
|
+
failure. See [Python helper policy](python-helper.md) for deployment guidance.
|
|
114
113
|
|
|
115
114
|
### Properties
|
|
116
115
|
|