@openclaw/fs-safe 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +6 -4
- package/dist/advanced.d.ts +1 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/device-path.d.ts +13 -0
- package/dist/device-path.d.ts.map +1 -0
- package/dist/device-path.js +112 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/fsync.d.ts +2 -0
- package/dist/fsync.d.ts.map +1 -0
- package/dist/fsync.js +21 -0
- package/dist/move-path.d.ts +3 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +13 -1
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -0
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +7 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +3 -0
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +3 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +50 -47
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +2 -0
- package/dist/walk.d.ts +13 -2
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +29 -6
- package/docs/contributing.md +1 -1
- package/docs/errors.md +3 -0
- package/docs/install.md +3 -3
- package/docs/path.md +13 -0
- package/docs/reading.md +6 -4
- package/docs/root.md +2 -1
- package/docs/security-model.md +3 -2
- package/docs/types.md +2 -1
- package/docs/walk.md +11 -1
- package/docs/writing.md +2 -2
- package/package.json +22 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-06-17
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Report unreadable walk roots and subdirectories through `failedDirs` from `walkDirectory()` and `walkDirectorySync()`, preserving readable results while letting destructive reconciliation distinguish incomplete scans from empty directories. (#29; thanks @amknight)
|
|
8
|
+
|
|
9
|
+
### Compatibility
|
|
10
|
+
|
|
11
|
+
- Require Node.js 22 or newer for the npm package and docs, matching the maintained CI matrix.
|
|
12
|
+
|
|
13
|
+
### Security and Correctness
|
|
14
|
+
|
|
15
|
+
- Fall back to the existing copy/remove move path when Windows denies directory renames with `EPERM`, preserving skill updates while watched files are locked. (#27; thanks @liuxingwei0601)
|
|
16
|
+
- Sync `root.append` file handles before close and sync the parent directory when append creates a file, preserving append-mode concurrency while improving durability. (#21; thanks @KumarAnandSingh)
|
|
17
|
+
- Reject known unsafe device and process-fd read paths before opening files, including `/dev/zero`, `/dev/random`, `/dev/fd/*`, `/proc/*/fd/*`, and Windows reserved device names.
|
|
18
|
+
|
|
3
19
|
## 0.3.0 - 2026-05-21
|
|
4
20
|
|
|
5
21
|
### Features
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# 🛡️ @openclaw/fs-safe
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/@openclaw/fs-safe)
|
|
4
6
|
[](https://github.com/openclaw/fs-safe/actions/workflows/ci.yml)
|
|
5
7
|
[](https://nodejs.org)
|
|
@@ -55,7 +57,7 @@ This is a **library-level guardrail**, not OS-level isolation. It does not repla
|
|
|
55
57
|
pnpm add @openclaw/fs-safe
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
Node
|
|
60
|
+
Node 22 or newer. Core root/path/json/temp helpers avoid framework dependencies. Archive helpers use optional `jszip` and `tar` dependencies for ZIP/TAR support; installs that omit optional dependencies can still use every non-archive subpath.
|
|
59
61
|
|
|
60
62
|
On POSIX, `root()` uses one process-global persistent Python helper for the
|
|
61
63
|
fd-relative operations Node does not expose ergonomically (`renameat`,
|
|
@@ -369,7 +371,7 @@ for (const file of scan.entries) {
|
|
|
369
371
|
}
|
|
370
372
|
```
|
|
371
373
|
|
|
372
|
-
Check `scan.truncated` before treating the result as complete.
|
|
374
|
+
Check `scan.truncated` before treating the result as complete, and `scan.failedDirs` to tell an incomplete scan (a directory that could not be read) from an empty one before pruning state from the listing.
|
|
373
375
|
|
|
374
376
|
## Archive extraction
|
|
375
377
|
|
|
@@ -439,12 +441,12 @@ if (err instanceof FsSafeError) {
|
|
|
439
441
|
}
|
|
440
442
|
```
|
|
441
443
|
|
|
442
|
-
Current `FsSafeErrorCode` values are `already-exists`, `hardlink`, `helper-failed`, `helper-unavailable`, `invalid-path`, `insecure-permissions`, `not-empty`, `not-file`, `not-found`, `not-owned`, `not-removable`, `outside-workspace`, `path-alias`, `path-mismatch`, `permission-unverified`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
|
|
444
|
+
Current `FsSafeErrorCode` values are `already-exists`, `denied-path`, `device-path`, `hardlink`, `helper-failed`, `helper-unavailable`, `invalid-path`, `insecure-permissions`, `not-empty`, `not-file`, `not-found`, `not-owned`, `not-removable`, `outside-workspace`, `path-alias`, `path-mismatch`, `permission-unverified`, `symlink`, `timeout`, `too-large`, and `unsupported-platform`.
|
|
443
445
|
|
|
444
446
|
## Safety model
|
|
445
447
|
|
|
446
448
|
- root-bounded APIs resolve paths against a configured root and reject canonical escapes
|
|
447
|
-
- reads open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
|
|
449
|
+
- reads reject known unsafe device paths, open with `O_NOFOLLOW` where available, then verify fd identity matches the path identity before returning the buffer or handle
|
|
448
450
|
- writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
|
|
449
451
|
- `remove`, `mkdir`, `move`, `stat`, `list`, and parent-fd writes use one persistent fd-relative Python helper on POSIX, with Node fallbacks when the helper is disabled or unavailable
|
|
450
452
|
- archive extraction stages into a private directory and merges through the same boundary checks used by direct writes
|
package/dist/advanced.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createAsyncLock } from "./async-lock.js";
|
|
2
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, type UnsafeDeviceReadPathMatch, type UnsafeDeviceReadPathOptions, type UnsafeDeviceReadPathReason, } from "./device-path.js";
|
|
2
3
|
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, type AbsolutePathSymlinkPolicy, type EnsureAbsoluteDirectoryOptions, type EnsureAbsoluteDirectoryResult, type ResolvedAbsolutePath, type ResolvedWritableAbsolutePath, } from "./absolute-path.js";
|
|
3
4
|
export { sameFileIdentity, type FileIdentityStat } from "./file-identity.js";
|
|
4
5
|
export { sanitizeUntrustedFileName } from "./filename.js";
|
package/dist/advanced.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,EACtB,yBAAyB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,uBAAuB,EACvB,iCAAiC,EACjC,uBAAuB,EACvB,oBAAoB,EACpB,0BAA0B,EAC1B,2BAA2B,EAC3B,KAAK,yBAAyB,EAC9B,KAAK,8BAA8B,EACnC,KAAK,6BAA6B,EAClC,KAAK,oBAAoB,EACzB,KAAK,4BAA4B,GAClC,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC7E,OAAO,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,EACL,6BAA6B,EAC7B,sBAAsB,EACtB,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,6BAA6B,EAClC,KAAK,oCAAoC,GAC1C,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,0BAA0B,EAC1B,uBAAuB,EACvB,0BAA0B,EAC1B,wBAAwB,EACxB,oBAAoB,EACpB,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,2BAA2B,EAC3B,uBAAuB,EACvB,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,wBAAwB,EACxB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,kBAAkB,GACxB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,wBAAwB,EACxB,kCAAkC,EAClC,eAAe,EACf,mBAAmB,EACnB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,yBAAyB,EACzB,SAAS,EACT,8BAA8B,EAC9B,qBAAqB,EACrB,sBAAsB,EACtB,oCAAoC,EACpC,6BAA6B,EAC7B,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,GAC7B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,qBAAqB,EACrB,6BAA6B,GAC9B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,sBAAsB,EACtB,0BAA0B,EAC1B,KAAK,6BAA6B,GACnC,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AACxD,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,eAAe,EACf,mBAAmB,EACnB,6BAA6B,EAC7B,eAAe,EACf,mBAAmB,EACnB,KAAK,wBAAwB,EAC7B,KAAK,qBAAqB,GAC3B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,KAAK,QAAQ,EACb,QAAQ,EACR,YAAY,GACb,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,wBAAwB,EACxB,wBAAwB,EACxB,uBAAuB,EACvB,iBAAiB,EACjB,iBAAiB,EACjB,2BAA2B,EAC3B,mBAAmB,EACnB,KAAK,yBAAyB,EAC9B,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAC"}
|
package/dist/advanced.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// public subpaths; prefer root/json/store/temp/archive unless you are building a
|
|
3
3
|
// higher-level primitive.
|
|
4
4
|
export { createAsyncLock } from "./async-lock.js";
|
|
5
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, } from "./device-path.js";
|
|
5
6
|
export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, } from "./absolute-path.js";
|
|
6
7
|
export { sameFileIdentity } from "./file-identity.js";
|
|
7
8
|
export { sanitizeUntrustedFileName } from "./filename.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type UnsafeDeviceReadPathReason = "posix-device" | "posix-fd" | "windows-device";
|
|
2
|
+
export type UnsafeDeviceReadPathMatch = {
|
|
3
|
+
path: string;
|
|
4
|
+
reason: UnsafeDeviceReadPathReason;
|
|
5
|
+
};
|
|
6
|
+
export type UnsafeDeviceReadPathOptions = {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
};
|
|
10
|
+
export declare function matchUnsafeDeviceReadPath(filePath: string, options?: UnsafeDeviceReadPathOptions): UnsafeDeviceReadPathMatch | undefined;
|
|
11
|
+
export declare function isUnsafeDeviceReadPath(filePath: string, options?: UnsafeDeviceReadPathOptions): boolean;
|
|
12
|
+
export declare function assertNoUnsafeDeviceReadPath(filePath: string, options?: UnsafeDeviceReadPathOptions): void;
|
|
13
|
+
//# sourceMappingURL=device-path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-path.d.ts","sourceRoot":"","sources":["../src/device-path.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,0BAA0B,GAClC,cAAc,GACd,UAAU,GACV,gBAAgB,CAAC;AAErB,MAAM,MAAM,yBAAyB,GAAG;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,0BAA0B,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CAC5B,CAAC;AAqGF,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,2BAAgC,GACxC,yBAAyB,GAAG,SAAS,CAWvC;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,2BAA2B,GACpC,OAAO,CAET;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,2BAA2B,GACpC,IAAI,CAON"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { trySafeFileURLToPath } from "./local-file-access.js";
|
|
4
|
+
const POSIX_BLOCKED_DEVICE_PATHS = new Set([
|
|
5
|
+
"/dev/zero",
|
|
6
|
+
"/dev/random",
|
|
7
|
+
"/dev/urandom",
|
|
8
|
+
"/dev/full",
|
|
9
|
+
"/dev/stdin",
|
|
10
|
+
"/dev/stdout",
|
|
11
|
+
"/dev/stderr",
|
|
12
|
+
"/dev/tty",
|
|
13
|
+
"/dev/console",
|
|
14
|
+
]);
|
|
15
|
+
const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
|
|
16
|
+
"CON",
|
|
17
|
+
"PRN",
|
|
18
|
+
"AUX",
|
|
19
|
+
"NUL",
|
|
20
|
+
"CLOCK$",
|
|
21
|
+
"CONIN$",
|
|
22
|
+
"CONOUT$",
|
|
23
|
+
"COM1",
|
|
24
|
+
"COM2",
|
|
25
|
+
"COM3",
|
|
26
|
+
"COM4",
|
|
27
|
+
"COM5",
|
|
28
|
+
"COM6",
|
|
29
|
+
"COM7",
|
|
30
|
+
"COM8",
|
|
31
|
+
"COM9",
|
|
32
|
+
"COM¹",
|
|
33
|
+
"COM²",
|
|
34
|
+
"COM³",
|
|
35
|
+
"LPT1",
|
|
36
|
+
"LPT2",
|
|
37
|
+
"LPT3",
|
|
38
|
+
"LPT4",
|
|
39
|
+
"LPT5",
|
|
40
|
+
"LPT6",
|
|
41
|
+
"LPT7",
|
|
42
|
+
"LPT8",
|
|
43
|
+
"LPT9",
|
|
44
|
+
"LPT¹",
|
|
45
|
+
"LPT²",
|
|
46
|
+
"LPT³",
|
|
47
|
+
]);
|
|
48
|
+
function candidateReadPaths(filePath) {
|
|
49
|
+
if (!filePath.startsWith("file://")) {
|
|
50
|
+
return [filePath];
|
|
51
|
+
}
|
|
52
|
+
const parsed = trySafeFileURLToPath(filePath);
|
|
53
|
+
return parsed === undefined ? [filePath] : [filePath, parsed];
|
|
54
|
+
}
|
|
55
|
+
function normalizePosixPath(filePath, cwd) {
|
|
56
|
+
if (path.posix.isAbsolute(filePath)) {
|
|
57
|
+
return path.posix.normalize(filePath);
|
|
58
|
+
}
|
|
59
|
+
const base = cwd && path.posix.isAbsolute(cwd) ? cwd : process.cwd();
|
|
60
|
+
return path.posix.resolve(base, filePath);
|
|
61
|
+
}
|
|
62
|
+
function matchPosixDeviceReadPath(filePath, cwd) {
|
|
63
|
+
const normalized = normalizePosixPath(filePath, cwd);
|
|
64
|
+
if (POSIX_BLOCKED_DEVICE_PATHS.has(normalized)) {
|
|
65
|
+
return { path: normalized, reason: "posix-device" };
|
|
66
|
+
}
|
|
67
|
+
if (normalized === "/dev/fd" || normalized.startsWith("/dev/fd/")) {
|
|
68
|
+
return { path: normalized, reason: "posix-fd" };
|
|
69
|
+
}
|
|
70
|
+
if (/^\/proc\/(?:self|thread-self|\d+)\/fd(?:\/|$)/.test(normalized)) {
|
|
71
|
+
return { path: normalized, reason: "posix-fd" };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function normalizeWindowsDeviceBaseName(filePath) {
|
|
76
|
+
const normalized = filePath.replace(/\//g, "\\").replace(/[\\]+$/g, "");
|
|
77
|
+
const lastSegment = normalized.split("\\").filter(Boolean).at(-1) ?? normalized;
|
|
78
|
+
const withoutStream = lastSegment.split(":")[0] ?? lastSegment;
|
|
79
|
+
const withoutTrailingIgnoredChars = withoutStream.replace(/[ .]+$/g, "");
|
|
80
|
+
return (withoutTrailingIgnoredChars.split(".")[0] ?? withoutTrailingIgnoredChars).toUpperCase();
|
|
81
|
+
}
|
|
82
|
+
function matchWindowsDeviceReadPath(filePath) {
|
|
83
|
+
const normalized = filePath.replace(/\//g, "\\");
|
|
84
|
+
if (/^\\\\\.\\/.test(normalized) || /^\\\\\?\\GLOBALROOT\\Device\\/i.test(normalized)) {
|
|
85
|
+
return { path: normalized, reason: "windows-device" };
|
|
86
|
+
}
|
|
87
|
+
const baseName = normalizeWindowsDeviceBaseName(filePath);
|
|
88
|
+
if (WINDOWS_RESERVED_DEVICE_NAMES.has(baseName)) {
|
|
89
|
+
return { path: normalized, reason: "windows-device" };
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
export function matchUnsafeDeviceReadPath(filePath, options = {}) {
|
|
94
|
+
const platform = options.platform ?? process.platform;
|
|
95
|
+
for (const candidate of candidateReadPaths(filePath)) {
|
|
96
|
+
const match = platform === "win32"
|
|
97
|
+
? matchWindowsDeviceReadPath(candidate)
|
|
98
|
+
: matchPosixDeviceReadPath(candidate, options.cwd);
|
|
99
|
+
if (match) {
|
|
100
|
+
return match;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
export function isUnsafeDeviceReadPath(filePath, options) {
|
|
106
|
+
return matchUnsafeDeviceReadPath(filePath, options) !== undefined;
|
|
107
|
+
}
|
|
108
|
+
export function assertNoUnsafeDeviceReadPath(filePath, options) {
|
|
109
|
+
if (matchUnsafeDeviceReadPath(filePath, options)) {
|
|
110
|
+
throw new FsSafeError("device-path", `file reads from unsafe device paths are not allowed: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FsSafeErrorCode = "already-exists" | "denied-path" | "hardlink" | "helper-failed" | "helper-unavailable" | "invalid-path" | "insecure-permissions" | "not-empty" | "not-file" | "not-found" | "not-owned" | "not-removable" | "outside-workspace" | "path-alias" | "path-mismatch" | "permission-unverified" | "symlink" | "timeout" | "too-large" | "unsupported-platform";
|
|
1
|
+
export type FsSafeErrorCode = "already-exists" | "denied-path" | "device-path" | "hardlink" | "helper-failed" | "helper-unavailable" | "invalid-path" | "insecure-permissions" | "not-empty" | "not-file" | "not-found" | "not-owned" | "not-removable" | "outside-workspace" | "path-alias" | "path-mismatch" | "permission-unverified" | "symlink" | "timeout" | "too-large" | "unsupported-platform";
|
|
2
2
|
export type FsSafeErrorCategory = "policy" | "operational";
|
|
3
3
|
export declare function categorizeFsSafeError(code: FsSafeErrorCode): FsSafeErrorCategory;
|
|
4
4
|
export declare class FsSafeError extends Error {
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,gBAAgB,GAChB,aAAa,GACb,UAAU,GACV,eAAe,GACf,oBAAoB,GACpB,cAAc,GACd,sBAAsB,GACtB,WAAW,GACX,UAAU,GACV,WAAW,GACX,WAAW,GACX,eAAe,GACf,mBAAmB,GACnB,YAAY,GACZ,eAAe,GACf,uBAAuB,GACvB,SAAS,GACT,SAAS,GACT,WAAW,GACX,sBAAsB,CAAC;AAE3B,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,aAAa,CAAC;AAU3D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,eAAe,GAAG,mBAAmB,CAEhF;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;gBAE3B,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAMtF"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,gBAAgB,GAChB,aAAa,GACb,aAAa,GACb,UAAU,GACV,eAAe,GACf,oBAAoB,GACpB,cAAc,GACd,sBAAsB,GACtB,WAAW,GACX,UAAU,GACV,WAAW,GACX,WAAW,GACX,eAAe,GACf,mBAAmB,GACnB,YAAY,GACZ,eAAe,GACf,uBAAuB,GACvB,SAAS,GACT,SAAS,GACT,WAAW,GACX,sBAAsB,CAAC;AAE3B,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,aAAa,CAAC;AAU3D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,eAAe,GAAG,mBAAmB,CAEhF;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;gBAE3B,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAMtF"}
|
package/dist/fsync.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fsync.d.ts","sourceRoot":"","sources":["../src/fsync.ts"],"names":[],"mappings":"AAIA,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAiB5E"}
|
package/dist/fsync.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
export async function syncDirectoryBestEffort(dirPath) {
|
|
4
|
+
if (process.platform === "win32") {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
let handle;
|
|
8
|
+
try {
|
|
9
|
+
const flags = fsSync.constants.O_RDONLY |
|
|
10
|
+
("O_DIRECTORY" in fsSync.constants ? fsSync.constants.O_DIRECTORY : 0) |
|
|
11
|
+
("O_NOFOLLOW" in fsSync.constants ? fsSync.constants.O_NOFOLLOW : 0);
|
|
12
|
+
handle = await fs.open(dirPath, flags);
|
|
13
|
+
await handle.sync();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Some filesystems reject directory handles; keep the write usable there.
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
await handle?.close().catch(() => undefined);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/move-path.d.ts
CHANGED
|
@@ -3,5 +3,8 @@ export type MovePathWithCopyFallbackOptions = {
|
|
|
3
3
|
sourceHardlinks?: "allow" | "reject";
|
|
4
4
|
to: string;
|
|
5
5
|
};
|
|
6
|
+
type MoveCopyFallbackReason = "cross-device" | "windows-rename-denied";
|
|
7
|
+
export declare function moveCopyFallbackReasonForRenameError(error: unknown, platform?: NodeJS.Platform): MoveCopyFallbackReason | undefined;
|
|
6
8
|
export declare function movePathWithCopyFallback(options: MovePathWithCopyFallbackOptions): Promise<void>;
|
|
9
|
+
export {};
|
|
7
10
|
//# sourceMappingURL=move-path.d.ts.map
|
package/dist/move-path.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;
|
|
1
|
+
{"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,KAAK,sBAAsB,GAAG,cAAc,GAAG,uBAAuB,CAAC;AAEvE,wBAAgB,oCAAoC,CAClD,KAAK,EAAE,OAAO,EACd,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,sBAAsB,GAAG,SAAS,CASpC;AAqPD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAyBf"}
|
package/dist/move-path.js
CHANGED
|
@@ -3,6 +3,16 @@ import { constants as fsConstants } from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { guardedRename } from "./guarded-mutation.js";
|
|
6
|
+
export function moveCopyFallbackReasonForRenameError(error, platform = process.platform) {
|
|
7
|
+
const code = error?.code;
|
|
8
|
+
if (code === "EXDEV") {
|
|
9
|
+
return "cross-device";
|
|
10
|
+
}
|
|
11
|
+
if (code === "EPERM" && platform === "win32") {
|
|
12
|
+
return "windows-rename-denied";
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
6
16
|
function entryIdentity(stat) {
|
|
7
17
|
return {
|
|
8
18
|
ctimeMs: stat.ctimeMs,
|
|
@@ -179,12 +189,14 @@ async function cleanupCopiedEntry(sourcePath, manifest) {
|
|
|
179
189
|
return "removed";
|
|
180
190
|
}
|
|
181
191
|
export async function movePathWithCopyFallback(options) {
|
|
192
|
+
let fallbackReason;
|
|
182
193
|
try {
|
|
183
194
|
await guardedRename({ from: options.from, to: options.to });
|
|
184
195
|
return;
|
|
185
196
|
}
|
|
186
197
|
catch (error) {
|
|
187
|
-
|
|
198
|
+
fallbackReason = moveCopyFallbackReasonForRenameError(error);
|
|
199
|
+
if (!fallbackReason) {
|
|
188
200
|
throw error;
|
|
189
201
|
}
|
|
190
202
|
}
|
package/dist/path.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, type UnsafeDeviceReadPathMatch, type UnsafeDeviceReadPathOptions, type UnsafeDeviceReadPathReason, } from "./device-path.js";
|
|
2
3
|
export declare function normalizeWindowsPathForComparison(input: string): string;
|
|
3
4
|
export declare function isNodeError(value: unknown): value is NodeJS.ErrnoException;
|
|
4
5
|
export declare function hasNodeErrorCode(value: unknown, code: string): boolean;
|
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;
|
|
1
|
+
{"version":3,"file":"path.d.ts","sourceRoot":"","sources":["../src/path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAMzB,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,EACtB,yBAAyB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,kBAAkB,CAAC;AAM1B,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
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
|
|
5
|
+
export { assertNoUnsafeDeviceReadPath, isUnsafeDeviceReadPath, matchUnsafeDeviceReadPath, } from "./device-path.js";
|
|
5
6
|
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
6
7
|
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
|
|
7
8
|
const POSIX_SEPARATOR_CHAR_CODE = 0x2f;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pinned-open.d.ts","sourceRoot":"","sources":["../src/pinned-open.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"pinned-open.d.ts","sourceRoot":"","sources":["../src/pinned-open.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAIzB,MAAM,MAAM,2BAA2B,GAAG,MAAM,GAAG,YAAY,GAAG,IAAI,CAAC;AAEvE,MAAM,MAAM,oBAAoB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAA;CAAE,GACtD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,2BAA2B,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAExE,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,WAAW,CAAC;AAE7D,MAAM,MAAM,gBAAgB,GAAG,IAAI,CACjC,OAAO,EAAE,EACT,WAAW,GAAG,WAAW,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CACpF,CAAC;AAYF,wBAAgB,kBAAkB,CAAC,MAAM,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,yBAAyB,CAAC;IACxC,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB,GAAG,oBAAoB,CAiEvB"}
|
package/dist/pinned-open.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { isUnsafeDeviceReadPath } from "./device-path.js";
|
|
2
3
|
import { sameFileIdentity as hasSameFileIdentity } from "./file-identity.js";
|
|
3
4
|
function isExpectedPathError(error) {
|
|
4
5
|
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
@@ -14,6 +15,9 @@ export function openPinnedFileSync(params) {
|
|
|
14
15
|
(typeof ioFs.constants.O_NOFOLLOW === "number" ? ioFs.constants.O_NOFOLLOW : 0);
|
|
15
16
|
let fd = null;
|
|
16
17
|
try {
|
|
18
|
+
if (isUnsafeDeviceReadPath(params.filePath)) {
|
|
19
|
+
return { ok: false, reason: "validation" };
|
|
20
|
+
}
|
|
17
21
|
if (params.rejectPathSymlink) {
|
|
18
22
|
const candidateStat = ioFs.lstatSync(params.filePath);
|
|
19
23
|
if (candidateStat.isSymbolicLink()) {
|
|
@@ -21,6 +25,9 @@ export function openPinnedFileSync(params) {
|
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
const realPath = params.resolvedPath ?? ioFs.realpathSync(params.filePath);
|
|
28
|
+
if (isUnsafeDeviceReadPath(realPath)) {
|
|
29
|
+
return { ok: false, reason: "validation" };
|
|
30
|
+
}
|
|
24
31
|
const preOpenStat = ioFs.lstatSync(realPath);
|
|
25
32
|
if (!isAllowedType(preOpenStat, allowedType)) {
|
|
26
33
|
return { ok: false, reason: "validation" };
|
|
@@ -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;
|
|
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;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
|
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
6
6
|
import { FsSafeError } from "./errors.js";
|
|
7
|
+
import { syncDirectoryBestEffort } from "./fsync.js";
|
|
7
8
|
import { sameFileIdentity } from "./file-identity.js";
|
|
8
9
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
9
10
|
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
@@ -201,11 +202,13 @@ async function runPinnedWriteFallback(params) {
|
|
|
201
202
|
throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
|
|
202
203
|
}
|
|
203
204
|
const expectedTempStat = tempStat;
|
|
205
|
+
await handle.sync();
|
|
204
206
|
await handle.close().catch(() => undefined);
|
|
205
207
|
handle = undefined;
|
|
206
208
|
await withAsyncDirectoryGuards([parentGuard], async () => {
|
|
207
209
|
await fs.rename(tempPath, targetPath);
|
|
208
210
|
renamed = true;
|
|
211
|
+
await syncDirectoryBestEffort(parentPath);
|
|
209
212
|
targetStat = await fs.lstat(targetPath);
|
|
210
213
|
if (targetStat.isSymbolicLink() || !sameFileIdentity(targetStat, expectedTempStat)) {
|
|
211
214
|
throw new FsSafeError("path-mismatch", "fallback target changed during write");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAU7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;AA2DD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CActF;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,CAc3E;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC,CAkD3C;AA6CD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IACpF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;CACb,CAqBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
|
package/dist/regular-file.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
|
|
4
5
|
import { FsSafeError } from "./errors.js";
|
|
5
6
|
import { sameFileIdentity } from "./file-identity.js";
|
|
6
7
|
import { isNotFoundPathError } from "./path.js";
|
|
@@ -89,6 +90,7 @@ export function statRegularFileSync(filePath) {
|
|
|
89
90
|
return { missing: false, stat };
|
|
90
91
|
}
|
|
91
92
|
export async function readRegularFile(params) {
|
|
93
|
+
assertNoUnsafeDeviceReadPath(params.filePath);
|
|
92
94
|
const result = await statRegularFile(params.filePath);
|
|
93
95
|
if (result.missing) {
|
|
94
96
|
throw Object.assign(new Error(`File not found: ${params.filePath}`), { code: "ENOENT" });
|
|
@@ -170,6 +172,7 @@ function readOpenedRegularFileSync(params) {
|
|
|
170
172
|
return { buffer, stat };
|
|
171
173
|
}
|
|
172
174
|
export function readRegularFileSync(params) {
|
|
175
|
+
assertNoUnsafeDeviceReadPath(params.filePath);
|
|
173
176
|
const result = statRegularFileSync(params.filePath);
|
|
174
177
|
if (result.missing) {
|
|
175
178
|
throw Object.assign(new Error(`File not found: ${params.filePath}`), { code: "ENOENT" });
|
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;
|
|
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
|
@@ -6,6 +6,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
6
6
|
import { createBoundedReadStream } from "./bounded-read-stream.js";
|
|
7
7
|
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
|
|
8
8
|
import { FsSafeError } from "./errors.js";
|
|
9
|
+
import { syncDirectoryBestEffort } from "./fsync.js";
|
|
9
10
|
import { sameFileIdentity } from "./file-identity.js";
|
|
10
11
|
import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
|
|
11
12
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
@@ -15,7 +16,7 @@ import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path
|
|
|
15
16
|
import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
|
|
16
17
|
import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
|
|
17
18
|
import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
|
|
18
|
-
import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
19
|
+
import { assertNoNulPathInput, assertNoUnsafeDeviceReadPath, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
|
|
19
20
|
import { readOpenedFileSafely } from "./read-opened-file.js";
|
|
20
21
|
import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
|
|
21
22
|
import { pathStatFromStats } from "./path-stat.js";
|
|
@@ -50,20 +51,16 @@ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
|
|
|
50
51
|
fsConstants.O_EXCL |
|
|
51
52
|
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
52
53
|
export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024;
|
|
53
|
-
function closeHandleForDispose(handle) {
|
|
54
|
-
return handle.close().catch(() => undefined);
|
|
55
|
-
}
|
|
56
54
|
function openResult(params) {
|
|
57
55
|
return {
|
|
58
56
|
handle: params.handle,
|
|
59
57
|
realPath: params.realPath,
|
|
60
58
|
stat: params.stat,
|
|
61
|
-
[Symbol.asyncDispose]:
|
|
62
|
-
await closeHandleForDispose(params.handle);
|
|
63
|
-
},
|
|
59
|
+
[Symbol.asyncDispose]: () => params.handle.close().catch(() => undefined),
|
|
64
60
|
};
|
|
65
61
|
}
|
|
66
62
|
async function openVerifiedLocalFile(filePath, options) {
|
|
63
|
+
assertNoUnsafeDeviceReadPath(filePath);
|
|
67
64
|
const fsSafeTestHooks = getFsSafeTestHooks();
|
|
68
65
|
// Reject directories before opening so we never surface EISDIR to callers (e.g. tool
|
|
69
66
|
// results that get sent to messaging channels). See openclaw/openclaw#31186.
|
|
@@ -588,9 +585,7 @@ async function openWritableFileInRoot(root, params) {
|
|
|
588
585
|
createdForWrite,
|
|
589
586
|
realPath,
|
|
590
587
|
stat,
|
|
591
|
-
[Symbol.asyncDispose]:
|
|
592
|
-
await closeHandleForDispose(handle);
|
|
593
|
-
},
|
|
588
|
+
[Symbol.asyncDispose]: () => handle.close().catch(() => undefined),
|
|
594
589
|
};
|
|
595
590
|
}
|
|
596
591
|
catch (err) {
|
|
@@ -627,10 +622,15 @@ async function appendFileInRoot(root, params) {
|
|
|
627
622
|
}
|
|
628
623
|
if (typeof params.data === "string") {
|
|
629
624
|
await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8");
|
|
630
|
-
return;
|
|
631
625
|
}
|
|
632
|
-
|
|
633
|
-
|
|
626
|
+
else {
|
|
627
|
+
const payload = prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
|
|
628
|
+
await target.handle.appendFile(payload);
|
|
629
|
+
}
|
|
630
|
+
await target.handle.sync();
|
|
631
|
+
if (target.createdForWrite) {
|
|
632
|
+
await syncDirectoryBestEffort(path.dirname(target.realPath));
|
|
633
|
+
}
|
|
634
634
|
}
|
|
635
635
|
finally {
|
|
636
636
|
await target.handle.close().catch(() => { });
|
|
@@ -687,42 +687,45 @@ async function writeFileInRoot(root, params) {
|
|
|
687
687
|
}
|
|
688
688
|
const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
|
|
689
689
|
await serializePathWrite(pinned.targetPath, async () => {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
throw
|
|
713
|
-
|
|
714
|
-
try {
|
|
715
|
-
await verifyAtomicWriteResult({
|
|
716
|
-
root,
|
|
717
|
-
targetPath: pinned.targetPath,
|
|
718
|
-
expectedIdentity: identity,
|
|
690
|
+
await commitPinnedWriteInRoot(root, pinned, params);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async function commitPinnedWriteInRoot(root, pinned, params) {
|
|
694
|
+
let identity;
|
|
695
|
+
try {
|
|
696
|
+
identity = await runPinnedWriteHelper({
|
|
697
|
+
rootPath: pinned.rootReal,
|
|
698
|
+
relativeParentPath: pinned.relativeParentPath,
|
|
699
|
+
basename: pinned.basename,
|
|
700
|
+
mkdir: params.mkdir !== false,
|
|
701
|
+
mode: params.mode ?? pinned.mode,
|
|
702
|
+
overwrite: params.overwrite,
|
|
703
|
+
input: {
|
|
704
|
+
kind: "buffer",
|
|
705
|
+
data: params.data,
|
|
706
|
+
encoding: params.encoding,
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
if (params.overwrite === false && isAlreadyExistsError(error)) {
|
|
712
|
+
throw new FsSafeError("already-exists", "file already exists", {
|
|
713
|
+
cause: error instanceof Error ? error : undefined,
|
|
719
714
|
});
|
|
720
715
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
716
|
+
throw normalizePinnedWriteError(error);
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
await verifyAtomicWriteResult({
|
|
720
|
+
root,
|
|
721
|
+
targetPath: pinned.targetPath,
|
|
722
|
+
expectedIdentity: identity,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
726
729
|
}
|
|
727
730
|
async function copyFileInRoot(root, params) {
|
|
728
731
|
assertValidRootRelativePath(params.relativePath);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAuKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
|
package/dist/secure-file.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants as fsConstants } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
|
|
4
5
|
import { FsSafeError } from "./errors.js";
|
|
5
6
|
import { sameFileIdentity } from "./file-identity.js";
|
|
6
7
|
import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
|
|
@@ -17,6 +18,7 @@ function label(options) {
|
|
|
17
18
|
return options.label ?? "Secure file";
|
|
18
19
|
}
|
|
19
20
|
async function openSecureHandle(options) {
|
|
21
|
+
assertNoUnsafeDeviceReadPath(options.filePath);
|
|
20
22
|
if (isWindowsNetworkPath(options.filePath, "win32") && !options.trust?.allowNetworkPath) {
|
|
21
23
|
throw new FsSafeError("invalid-path", `${label(options)} must be a local absolute path.`);
|
|
22
24
|
}
|
package/dist/walk.d.ts
CHANGED
|
@@ -16,11 +16,22 @@ export type WalkDirectoryOptions = {
|
|
|
16
16
|
include?: (entry: WalkDirectoryEntry) => boolean;
|
|
17
17
|
descend?: (entry: WalkDirectoryEntry) => boolean;
|
|
18
18
|
};
|
|
19
|
+
export type WalkDirectoryFailure = {
|
|
20
|
+
path: string;
|
|
21
|
+
relativePath: string;
|
|
22
|
+
depth: number;
|
|
23
|
+
error: unknown;
|
|
24
|
+
};
|
|
19
25
|
export type WalkDirectoryResult = {
|
|
20
26
|
entries: WalkDirectoryEntry[];
|
|
21
27
|
scannedEntryCount: number;
|
|
22
28
|
truncated: boolean;
|
|
29
|
+
failedDirs?: WalkDirectoryFailure[];
|
|
30
|
+
};
|
|
31
|
+
type WalkDirectoryResultWithFailures = WalkDirectoryResult & {
|
|
32
|
+
failedDirs: WalkDirectoryFailure[];
|
|
23
33
|
};
|
|
24
|
-
export declare function walkDirectorySync(rootDir: string, options?: WalkDirectoryOptions):
|
|
25
|
-
export declare function walkDirectory(rootDir: string, options?: WalkDirectoryOptions): Promise<
|
|
34
|
+
export declare function walkDirectorySync(rootDir: string, options?: WalkDirectoryOptions): WalkDirectoryResultWithFailures;
|
|
35
|
+
export declare function walkDirectory(rootDir: string, options?: WalkDirectoryOptions): Promise<WalkDirectoryResultWithFailures>;
|
|
36
|
+
export {};
|
|
26
37
|
//# sourceMappingURL=walk.d.ts.map
|
package/dist/walk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"walk.d.ts","sourceRoot":"","sources":["../src/walk.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAI7B,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AACvE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE9D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"walk.d.ts","sourceRoot":"","sources":["../src/walk.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,SAAS,CAAC;AAI7B,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AACvE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE9D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;IAGnB,UAAU,CAAC,EAAE,oBAAoB,EAAE,CAAC;CACrC,CAAC;AAEF,KAAK,+BAA+B,GAAG,mBAAmB,GAAG;IAC3D,UAAU,EAAE,oBAAoB,EAAE,CAAC;CACpC,CAAC;AA8EF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,+BAA+B,CAwDjC;AAED,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,+BAA+B,CAAC,CAwD1C"}
|
package/dist/walk.js
CHANGED
|
@@ -25,6 +25,15 @@ function buildEntry(params) {
|
|
|
25
25
|
dirent: params.dirent,
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
function recordFailedDir(result, root, dir, depth, error) {
|
|
29
|
+
const relativePath = path.relative(root, dir);
|
|
30
|
+
result.failedDirs.push({
|
|
31
|
+
path: dir,
|
|
32
|
+
relativePath,
|
|
33
|
+
depth: relativePath === "" ? 0 : depth - 1,
|
|
34
|
+
error,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
28
37
|
function resolveSyncKind(fullPath, dirent, symlinks) {
|
|
29
38
|
const kind = kindForDirent(dirent);
|
|
30
39
|
if (kind !== "symlink")
|
|
@@ -68,7 +77,12 @@ async function resolveAsyncKind(fullPath, dirent, symlinks) {
|
|
|
68
77
|
export function walkDirectorySync(rootDir, options = {}) {
|
|
69
78
|
const root = path.resolve(rootDir);
|
|
70
79
|
const symlinks = options.symlinks ?? "skip";
|
|
71
|
-
const result = {
|
|
80
|
+
const result = {
|
|
81
|
+
entries: [],
|
|
82
|
+
scannedEntryCount: 0,
|
|
83
|
+
truncated: false,
|
|
84
|
+
failedDirs: [],
|
|
85
|
+
};
|
|
72
86
|
const visitedDirs = new Set();
|
|
73
87
|
function visit(dir, depth) {
|
|
74
88
|
if (options.maxDepth !== undefined && depth > options.maxDepth)
|
|
@@ -77,7 +91,8 @@ export function walkDirectorySync(rootDir, options = {}) {
|
|
|
77
91
|
try {
|
|
78
92
|
realDir = fsSync.realpathSync(dir);
|
|
79
93
|
}
|
|
80
|
-
catch {
|
|
94
|
+
catch (error) {
|
|
95
|
+
recordFailedDir(result, root, dir, depth, error);
|
|
81
96
|
return;
|
|
82
97
|
}
|
|
83
98
|
if (visitedDirs.has(realDir))
|
|
@@ -87,7 +102,8 @@ export function walkDirectorySync(rootDir, options = {}) {
|
|
|
87
102
|
try {
|
|
88
103
|
entries = fsSync.readdirSync(dir, { withFileTypes: true });
|
|
89
104
|
}
|
|
90
|
-
catch {
|
|
105
|
+
catch (error) {
|
|
106
|
+
recordFailedDir(result, root, dir, depth, error);
|
|
91
107
|
return;
|
|
92
108
|
}
|
|
93
109
|
for (const dirent of entries) {
|
|
@@ -119,7 +135,12 @@ export function walkDirectorySync(rootDir, options = {}) {
|
|
|
119
135
|
export async function walkDirectory(rootDir, options = {}) {
|
|
120
136
|
const root = path.resolve(rootDir);
|
|
121
137
|
const symlinks = options.symlinks ?? "skip";
|
|
122
|
-
const result = {
|
|
138
|
+
const result = {
|
|
139
|
+
entries: [],
|
|
140
|
+
scannedEntryCount: 0,
|
|
141
|
+
truncated: false,
|
|
142
|
+
failedDirs: [],
|
|
143
|
+
};
|
|
123
144
|
const visitedDirs = new Set();
|
|
124
145
|
async function visit(dir, depth) {
|
|
125
146
|
if (options.maxDepth !== undefined && depth > options.maxDepth)
|
|
@@ -128,7 +149,8 @@ export async function walkDirectory(rootDir, options = {}) {
|
|
|
128
149
|
try {
|
|
129
150
|
realDir = await fs.realpath(dir);
|
|
130
151
|
}
|
|
131
|
-
catch {
|
|
152
|
+
catch (error) {
|
|
153
|
+
recordFailedDir(result, root, dir, depth, error);
|
|
132
154
|
return;
|
|
133
155
|
}
|
|
134
156
|
if (visitedDirs.has(realDir))
|
|
@@ -138,7 +160,8 @@ export async function walkDirectory(rootDir, options = {}) {
|
|
|
138
160
|
try {
|
|
139
161
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
140
162
|
}
|
|
141
|
-
catch {
|
|
163
|
+
catch (error) {
|
|
164
|
+
recordFailedDir(result, root, dir, depth, error);
|
|
142
165
|
return;
|
|
143
166
|
}
|
|
144
167
|
for (const dirent of entries) {
|
package/docs/contributing.md
CHANGED
|
@@ -10,7 +10,7 @@ cd fs-safe
|
|
|
10
10
|
pnpm install
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Node
|
|
13
|
+
Node 22 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
|
|
14
14
|
|
|
15
15
|
## Build
|
|
16
16
|
|
package/docs/errors.md
CHANGED
|
@@ -31,6 +31,7 @@ class FsSafeError extends Error {
|
|
|
31
31
|
type FsSafeErrorCode =
|
|
32
32
|
| "already-exists"
|
|
33
33
|
| "denied-path"
|
|
34
|
+
| "device-path"
|
|
34
35
|
| "hardlink"
|
|
35
36
|
| "helper-failed"
|
|
36
37
|
| "helper-unavailable"
|
|
@@ -57,6 +58,7 @@ type FsSafeErrorCode =
|
|
|
57
58
|
|---|---|---|
|
|
58
59
|
| `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
|
|
59
60
|
| `denied-path` | A root mutation matched `denyMutations.paths` or `denyMutations.prefixes`. | Caller configured application-sensitive paths that must not be written, removed, moved, or created. |
|
|
61
|
+
| `device-path` | A read/open target is a known unsafe device or process-fd path. | `/dev/zero`, `/dev/random`, `/dev/stdin`, `/dev/fd/*`, `/proc/*/fd/*`, or a Windows reserved device name. |
|
|
60
62
|
| `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
|
|
61
63
|
| `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
|
|
62
64
|
| `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
|
|
@@ -95,6 +97,7 @@ try {
|
|
|
95
97
|
case "not-found":
|
|
96
98
|
return reply(404, "missing");
|
|
97
99
|
case "symlink":
|
|
100
|
+
case "device-path":
|
|
98
101
|
case "hardlink":
|
|
99
102
|
case "path-mismatch":
|
|
100
103
|
case "path-alias":
|
package/docs/install.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Install
|
|
2
2
|
|
|
3
|
-
`fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node
|
|
3
|
+
`fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node 22 or newer, ships ESM only, and works on macOS, Linux, and Windows.
|
|
4
4
|
|
|
5
5
|
## Package managers
|
|
6
6
|
|
|
@@ -22,13 +22,13 @@ bun add @openclaw/fs-safe
|
|
|
22
22
|
|
|
23
23
|
## Node version
|
|
24
24
|
|
|
25
|
-
Minimum **Node
|
|
25
|
+
Minimum **Node 22**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
|
|
26
26
|
|
|
27
27
|
Verify the runtime:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
node --version
|
|
31
|
-
#
|
|
31
|
+
# v22.0.0 or newer
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## TypeScript
|
package/docs/path.md
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
safeRealpathSync,
|
|
12
12
|
safeStatSync,
|
|
13
13
|
assertNoNulPathInput,
|
|
14
|
+
assertNoUnsafeDeviceReadPath,
|
|
15
|
+
isUnsafeDeviceReadPath,
|
|
14
16
|
isNotFoundPathError,
|
|
15
17
|
isSymlinkOpenError,
|
|
16
18
|
hasNodeErrorCode,
|
|
@@ -89,6 +91,17 @@ if (!stat?.isFile()) return notFound();
|
|
|
89
91
|
|
|
90
92
|
Throws `FsSafeError` with code `invalid-path` when a path string contains an embedded NUL byte. Use it before calling Node `fs` APIs directly; Node's native error can include raw path text in the message.
|
|
91
93
|
|
|
94
|
+
### `assertNoUnsafeDeviceReadPath(filePath, options?)`
|
|
95
|
+
|
|
96
|
+
Throws `FsSafeError` with code `device-path` when a read target is a known unsafe device or process-fd path. The built-in read/open helpers call this automatically before opening files; use it only when you are building your own read primitive.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
assertNoUnsafeDeviceReadPath("/dev/zero"); // throws on POSIX
|
|
100
|
+
isUnsafeDeviceReadPath("/dev/fd/0"); // true on POSIX
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The check is intentionally not a normal consumer policy knob. Safe read APIs reject these targets by default because they can block forever, stream indefinitely, or alias process file descriptors.
|
|
104
|
+
|
|
92
105
|
## Error inspection
|
|
93
106
|
|
|
94
107
|
### `isNotFoundPathError(err)`
|
package/docs/reading.md
CHANGED
|
@@ -17,10 +17,11 @@ Regardless of shape, every read goes through the same boundary checks:
|
|
|
17
17
|
1. Resolve the relative path against the canonical real root.
|
|
18
18
|
2. Reject anything that escapes the root (`outside-workspace`).
|
|
19
19
|
3. Reject `..` segments and absolute inputs (unless via `readAbsolute` with an in-root absolute path).
|
|
20
|
-
4.
|
|
21
|
-
5.
|
|
22
|
-
6.
|
|
23
|
-
7. If `
|
|
20
|
+
4. Reject known unsafe device and process-fd paths before opening (`device-path`).
|
|
21
|
+
5. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
|
|
22
|
+
6. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
|
|
23
|
+
7. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
|
|
24
|
+
8. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
|
|
24
25
|
|
|
25
26
|
## Read shapes
|
|
26
27
|
|
|
@@ -160,6 +161,7 @@ try {
|
|
|
160
161
|
- **`outside-workspace`** — relative path escaped the root, or `readAbsolute` got an absolute path outside.
|
|
161
162
|
- **`not-found`** — the file is gone.
|
|
162
163
|
- **`not-file`** — you read a directory or a non-regular file (FIFO, socket, …).
|
|
164
|
+
- **`device-path`** — the path targets a known unsafe device or process fd path.
|
|
163
165
|
- **`symlink`** — a path component is a symlink and the policy is `reject`.
|
|
164
166
|
- **`path-mismatch`** — opened fd identity did not match the resolved path. Almost always a TOCTOU swap by something else.
|
|
165
167
|
- **`hardlink`** — `hardlinks: "reject"` saw `nlink > 1`.
|
package/docs/root.md
CHANGED
|
@@ -71,7 +71,7 @@ fs.write(rel, data, options?) // overwrite-ok atomic write
|
|
|
71
71
|
fs.create(rel, data, options?) // throws "already-exists" if target exists
|
|
72
72
|
fs.writeJson(rel, value, options?) // JSON.stringify + atomic write
|
|
73
73
|
fs.createJson(rel, value, options?) // create() variant of writeJson
|
|
74
|
-
fs.append(rel, data, options?) // append text/buffer;
|
|
74
|
+
fs.append(rel, data, options?) // append text/buffer; syncs before close
|
|
75
75
|
fs.copyIn(rel, sourceAbsPath, options?) // copy from outside the root, atomically, with size cap
|
|
76
76
|
fs.openWritable(rel, options?) // FileHandle for streaming writes; supports await using
|
|
77
77
|
fs.move(from, to, options?) // rename within the root; defaults to no clobber
|
|
@@ -139,6 +139,7 @@ Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not messa
|
|
|
139
139
|
| `outside-workspace` | The input resolves outside the root, or contains a `..` segment that would escape it. |
|
|
140
140
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
|
|
141
141
|
| `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
|
|
142
|
+
| `device-path` | A read/open target is a known unsafe device or process-fd path. |
|
|
142
143
|
| `already-exists` | `create()` or `move()` without `overwrite` hit an existing target. |
|
|
143
144
|
| `denied-path` | A mutation target matched `denyMutations.paths` or `denyMutations.prefixes`. |
|
|
144
145
|
| `symlink` | A path component is a symlink, and the call's `symlinks` policy is `reject`. |
|
package/docs/security-model.md
CHANGED
|
@@ -13,6 +13,7 @@ You hand a `root()` boundary to a piece of code that takes caller-controlled rel
|
|
|
13
13
|
- replaces a path component with a symlink between check and use (TOCTOU)
|
|
14
14
|
- replaces the destination directory with a symlink right before a write
|
|
15
15
|
- creates a hardlink that aliases an out-of-tree inode and asks you to read or replace it
|
|
16
|
+
- asks a read/open primitive to target a known unsafe device or process-fd path
|
|
16
17
|
- triggers a partial write that leaves a half-written file at the destination
|
|
17
18
|
- ships an archive with `..` paths, absolute paths, or symlinks pointing outside the destination
|
|
18
19
|
|
|
@@ -20,7 +21,7 @@ It does **not** defend against:
|
|
|
20
21
|
|
|
21
22
|
- a process running with permissions to write anywhere on the filesystem and choosing to ignore the library
|
|
22
23
|
- another process with the same UID racing to mutate the same directory between two separate `fs-safe` calls — the boundary is per-call, not per-session
|
|
23
|
-
- traversal across filesystem boundaries, bind mounts,
|
|
24
|
+
- arbitrary traversal across filesystem boundaries, bind mounts, or virtual filesystems beyond the known unsafe read device paths
|
|
24
25
|
- container escape, TOCTOU between fork and exec of helpers, or kernel-level vulnerabilities
|
|
25
26
|
- semantic content checks: file types, archive payload schemas, signature verification
|
|
26
27
|
|
|
@@ -83,7 +84,7 @@ The library does not advertise different security guarantees per platform — it
|
|
|
83
84
|
|---|---|
|
|
84
85
|
| Not ambient authority removal | Code that can import `node:fs` can still bypass the handle. Keep caller-controlled path operations behind `root()` by convention, review, and tests. |
|
|
85
86
|
| Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
|
|
86
|
-
| Not a mount
|
|
87
|
+
| Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
|
|
87
88
|
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between two separate calls. Use one verb method for the operation you need to make race-resistant. |
|
|
88
89
|
| Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
|
|
89
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. |
|
package/docs/types.md
CHANGED
|
@@ -148,7 +148,8 @@ The two policy unions you'll see throughout. `"reject"` is conservative; `"follo
|
|
|
148
148
|
|
|
149
149
|
```ts
|
|
150
150
|
type FsSafeErrorCode =
|
|
151
|
-
| "already-exists" | "denied-path" | "
|
|
151
|
+
| "already-exists" | "denied-path" | "device-path" | "hardlink"
|
|
152
|
+
| "helper-failed"
|
|
152
153
|
| "helper-unavailable" | "insecure-permissions" | "invalid-path"
|
|
153
154
|
| "not-empty" | "not-file" | "not-found" | "not-owned"
|
|
154
155
|
| "not-removable" | "outside-workspace" | "path-alias"
|
package/docs/walk.md
CHANGED
|
@@ -25,6 +25,7 @@ type WalkDirectoryResult = {
|
|
|
25
25
|
entries: WalkDirectoryEntry[];
|
|
26
26
|
scannedEntryCount: number;
|
|
27
27
|
truncated: boolean;
|
|
28
|
+
failedDirs?: WalkDirectoryFailure[];
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
type WalkDirectoryEntry = {
|
|
@@ -35,10 +36,19 @@ type WalkDirectoryEntry = {
|
|
|
35
36
|
kind: "file" | "directory" | "symlink" | "other";
|
|
36
37
|
dirent: import("node:fs").Dirent;
|
|
37
38
|
};
|
|
39
|
+
|
|
40
|
+
type WalkDirectoryFailure = {
|
|
41
|
+
path: string;
|
|
42
|
+
relativePath: string;
|
|
43
|
+
depth: number;
|
|
44
|
+
error: unknown;
|
|
45
|
+
};
|
|
38
46
|
```
|
|
39
47
|
|
|
40
48
|
`depth` starts at `1` for direct children of `rootDir`. `relativePath` is always relative to the supplied root. `scannedEntryCount` counts directory entries examined, including entries filtered out by `include`.
|
|
41
49
|
|
|
50
|
+
`walkDirectory()` and `walkDirectorySync()` always return `failedDirs`; the property remains optional on the exported `WalkDirectoryResult` type so existing callers that manually construct the legacy result shape remain source-compatible. It lists every directory whose `realpath`/`readdir` threw, so its contents are absent from `entries`. `error` is the thrown value (a `NodeJS.ErrnoException` at runtime), so callers can distinguish a benign missing-directory race (`ENOENT`) from a real read failure (`EACCES`, `EIO`, `ESTALE`, …). The walk-root failure has an empty `relativePath` and `depth: 0`. Failures resolving a symlink's target kind are not reported here.
|
|
51
|
+
|
|
42
52
|
## Options
|
|
43
53
|
|
|
44
54
|
```ts
|
|
@@ -55,7 +65,7 @@ type WalkDirectoryOptions = {
|
|
|
55
65
|
|
|
56
66
|
`include` controls which entries are returned. `descend` controls which directory entries are traversed. A skipped directory can still be returned if `include` accepts it.
|
|
57
67
|
|
|
58
|
-
Unreadable directories are skipped
|
|
68
|
+
Unreadable directories are skipped rather than throwing, but every skipped directory is recorded in `failedDirs`. This keeps the helper suitable for best-effort inventories while letting pruning jobs tell an incomplete scan from an empty one: a destructive reconcile that deletes state for paths missing from `entries` must first confirm `failedDirs` holds no real read failures, or a transient `EIO`/`EACCES` blip would be mistaken for mass deletion. Use a stricter root-bounded operation when every entry must be accounted for.
|
|
59
69
|
|
|
60
70
|
## See also
|
|
61
71
|
|
package/docs/writing.md
CHANGED
|
@@ -93,14 +93,14 @@ type RootWriteJsonOptions = {
|
|
|
93
93
|
|
|
94
94
|
### `fs.append(rel, data, options?)`
|
|
95
95
|
|
|
96
|
-
Open in append mode, write, close. Honors `mkdir` for the parent directory. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
|
|
96
|
+
Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
|
|
97
97
|
|
|
98
98
|
```ts
|
|
99
99
|
await fs.append("logs/today.log", `[${ts}] ${line}\n`);
|
|
100
100
|
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle.
|
|
103
|
+
For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle. Direct append-mode writes preserve kernel append semantics, but they are not atomic against external rotators that rename or unlink the target.
|
|
104
104
|
|
|
105
105
|
### `fs.copyIn(rel, sourceAbsPath, options?)`
|
|
106
106
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -97,29 +97,34 @@
|
|
|
97
97
|
"default": "./dist/test-hooks.js"
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
|
+
"optionalDependencies": {
|
|
101
|
+
"jszip": "^3.10.1",
|
|
102
|
+
"tar": "7.5.16"
|
|
103
|
+
},
|
|
104
|
+
"devDependencies": {
|
|
105
|
+
"@types/node": "^22.19.20",
|
|
106
|
+
"@vitest/coverage-v8": "4.1.8",
|
|
107
|
+
"typescript": "^5.9.3",
|
|
108
|
+
"vitest": "^4.1.8"
|
|
109
|
+
},
|
|
110
|
+
"engines": {
|
|
111
|
+
"node": ">=22"
|
|
112
|
+
},
|
|
100
113
|
"scripts": {
|
|
101
114
|
"benchmark": "node scripts/benchmark.mjs",
|
|
102
115
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
103
116
|
"lint:file-size": "node scripts/check-file-size.mjs",
|
|
104
117
|
"lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
|
|
105
|
-
"prepack": "node scripts/prepack-build.mjs",
|
|
106
118
|
"test": "vitest run",
|
|
107
119
|
"test:coverage": "vitest run --coverage",
|
|
108
120
|
"test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
|
|
109
121
|
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
|
|
110
|
-
"docs:site": "node scripts/build-docs-site.mjs"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"@types/node": "^22.15.19",
|
|
118
|
-
"@vitest/coverage-v8": "4.1.6",
|
|
119
|
-
"typescript": "^5.8.3",
|
|
120
|
-
"vitest": "^4.1.6"
|
|
121
|
-
},
|
|
122
|
-
"engines": {
|
|
123
|
-
"node": ">=20.11"
|
|
122
|
+
"docs:site": "node scripts/build-docs-site.mjs",
|
|
123
|
+
"check:changed": "pnpm run check",
|
|
124
|
+
"test:changed": "pnpm run test",
|
|
125
|
+
"crabbox:hydrate": "crabbox actions hydrate",
|
|
126
|
+
"crabbox:run": "crabbox run",
|
|
127
|
+
"crabbox:stop": "crabbox stop",
|
|
128
|
+
"crabbox:warmup": "crabbox warmup"
|
|
124
129
|
}
|
|
125
|
-
}
|
|
130
|
+
}
|