@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 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
+ ![fs-safe banner](docs/assets/readme-banner.jpg)
4
+
3
5
  [![npm](https://img.shields.io/npm/v/@openclaw/fs-safe.svg?color=10b981&label=npm)](https://www.npmjs.com/package/@openclaw/fs-safe)
4
6
  [![ci](https://github.com/openclaw/fs-safe/actions/workflows/ci.yml/badge.svg)](https://github.com/openclaw/fs-safe/actions/workflows/ci.yml)
5
7
  [![node](https://img.shields.io/node/v/@openclaw/fs-safe.svg?color=10b981)](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 20.11 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.
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
@@ -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";
@@ -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":"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"}
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 {
@@ -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"}
@@ -0,0 +1,2 @@
1
+ export declare function syncDirectoryBestEffort(dirPath: string): Promise<void>;
2
+ //# sourceMappingURL=fsync.d.ts.map
@@ -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
+ }
@@ -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
@@ -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;AAqPF,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAuBf"}
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
- if (error?.code !== "EXDEV") {
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;
@@ -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,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"}
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;AAGzB,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,CA2DvB"}
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"}
@@ -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;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"}
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"}
@@ -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;AAS7B,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,CAiD3C;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,CAoBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
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"}
@@ -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" });
@@ -1 +1 @@
1
- {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
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]: async () => {
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]: async () => {
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
- const payload = prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
633
- await target.handle.appendFile(payload);
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
- let identity;
691
- try {
692
- identity = await runPinnedWriteHelper({
693
- rootPath: pinned.rootReal,
694
- relativeParentPath: pinned.relativeParentPath,
695
- basename: pinned.basename,
696
- mkdir: params.mkdir !== false,
697
- mode: params.mode ?? pinned.mode,
698
- overwrite: params.overwrite,
699
- input: {
700
- kind: "buffer",
701
- data: params.data,
702
- encoding: params.encoding,
703
- },
704
- });
705
- }
706
- catch (error) {
707
- if (params.overwrite === false && isAlreadyExistsError(error)) {
708
- throw new FsSafeError("already-exists", "file already exists", {
709
- cause: error instanceof Error ? error : undefined,
710
- });
711
- }
712
- throw normalizePinnedWriteError(error);
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
- catch (err) {
722
- emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
723
- throw err;
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;AAUrC,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;AAsKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
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"}
@@ -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): WalkDirectoryResult;
25
- export declare function walkDirectory(rootDir: string, options?: WalkDirectoryOptions): Promise<WalkDirectoryResult>;
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
@@ -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;CACpB,CAAC;AA8DF,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,mBAAmB,CAiDrB;AAED,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAiD9B"}
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 = { entries: [], scannedEntryCount: 0, truncated: false };
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 = { entries: [], scannedEntryCount: 0, truncated: false };
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) {
@@ -10,7 +10,7 @@ cd fs-safe
10
10
  pnpm install
11
11
  ```
12
12
 
13
- Node 20.11 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
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 20.11 or newer, ships ESM only, and works on macOS, Linux, and Windows.
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 20.11**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
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
- # v20.11.0 or newer
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. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
21
- 5. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
22
- 6. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
23
- 7. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
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; respects mkdir default
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`. |
@@ -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, device files, `/proc`-style virtual filesystems, or any other path your process can normally access from inside the root
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/device boundary | `root()` keeps path traversal inside the directory tree; it does not make device files, bind mounts, or virtual filesystems safe to expose. |
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" | "hardlink" | "helper-failed"
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. This makes the helper suitable for best-effort inventories and pruning jobs; use a stricter root-bounded operation when every entry must be accounted for.
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.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
- "optionalDependencies": {
113
- "jszip": "^3.10.1",
114
- "tar": "7.5.13"
115
- },
116
- "devDependencies": {
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
+ }