@openclaw/fs-safe 0.2.7 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -5
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/deny-mutations.d.ts +11 -0
  7. package/dist/deny-mutations.d.ts.map +1 -0
  8. package/dist/deny-mutations.js +102 -0
  9. package/dist/device-path.d.ts +13 -0
  10. package/dist/device-path.d.ts.map +1 -0
  11. package/dist/device-path.js +112 -0
  12. package/dist/errors.d.ts +1 -1
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/fsync.d.ts +2 -0
  15. package/dist/fsync.d.ts.map +1 -0
  16. package/dist/fsync.js +21 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/json.d.ts.map +1 -1
  20. package/dist/json.js +51 -4
  21. package/dist/move-path.d.ts +3 -0
  22. package/dist/move-path.d.ts.map +1 -1
  23. package/dist/move-path.js +13 -1
  24. package/dist/opened-realpath.d.ts +3 -0
  25. package/dist/opened-realpath.d.ts.map +1 -0
  26. package/dist/opened-realpath.js +79 -0
  27. package/dist/path.d.ts +1 -0
  28. package/dist/path.d.ts.map +1 -1
  29. package/dist/path.js +1 -0
  30. package/dist/pinned-open.d.ts.map +1 -1
  31. package/dist/pinned-open.js +7 -0
  32. package/dist/pinned-write.d.ts.map +1 -1
  33. package/dist/pinned-write.js +3 -0
  34. package/dist/read-opened-file.d.ts +18 -0
  35. package/dist/read-opened-file.d.ts.map +1 -0
  36. package/dist/read-opened-file.js +15 -0
  37. package/dist/regular-file.d.ts.map +1 -1
  38. package/dist/regular-file.js +26 -3
  39. package/dist/root-impl.d.ts +18 -15
  40. package/dist/root-impl.d.ts.map +1 -1
  41. package/dist/root-impl.js +110 -147
  42. package/dist/root.d.ts +1 -1
  43. package/dist/root.d.ts.map +1 -1
  44. package/dist/secure-file.d.ts.map +1 -1
  45. package/dist/secure-file.js +2 -0
  46. package/dist/walk.d.ts +13 -2
  47. package/dist/walk.d.ts.map +1 -1
  48. package/dist/walk.js +29 -6
  49. package/docs/contributing.md +1 -1
  50. package/docs/errors.md +5 -0
  51. package/docs/install.md +3 -3
  52. package/docs/path.md +13 -0
  53. package/docs/reading.md +6 -4
  54. package/docs/root.md +15 -5
  55. package/docs/security-model.md +7 -2
  56. package/docs/types.md +22 -9
  57. package/docs/walk.md +11 -1
  58. package/docs/writing.md +21 -3
  59. package/package.json +22 -17
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
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
+
19
+ ## 0.3.0 - 2026-05-21
20
+
21
+ ### Features
22
+
23
+ - Add opt-in `denyMutations` policies with exact `paths` and subtree `prefixes` so callers can protect application-sensitive files from root write, copy, move, remove, mkdir, and writable-open operations. (#20; thanks @amknight)
24
+
25
+ ### Security and Correctness
26
+
27
+ - Retry async JSON reads (`readJson`, `readJsonIfExists`, `tryReadJson`) up to five attempts with 50ms exponential backoff when the file is rotated mid-read by an atomic rename, and tag the underlying race as `FsSafeError("path-mismatch")` so callers can distinguish transient swaps from corruption. (#19; thanks @yetval)
28
+
3
29
  ## 0.2.7 - 2026-05-20
4
30
 
5
31
  ### Security and Correctness
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`,
@@ -155,7 +157,18 @@ await using opened = await fs.openWritable("logs/current.log", { writeMode: "app
155
157
  }
156
158
  ```
157
159
 
158
- `nonBlockingRead` is the only I/O scheduling knob in `RootDefaults`; it applies to read/open operations because it changes how file descriptors are opened. Filesystem safety policy remains explicit through `hardlinks` and `symlinks`.
160
+ `nonBlockingRead` is the only I/O scheduling knob in `RootDefaults`; it applies to read/open operations because it changes how file descriptors are opened. Filesystem safety policy remains explicit through `hardlinks`, `symlinks`, and `denyMutations`.
161
+
162
+ ```ts
163
+ const locked = await root("/srv/workspace", {
164
+ denyMutations: {
165
+ paths: ["/srv/workspace/.env"],
166
+ prefixes: ["/srv/workspace/.ssh"],
167
+ },
168
+ });
169
+
170
+ await locked.write(".env", "token"); // FsSafeError code "denied-path"
171
+ ```
159
172
 
160
173
  `stat()`, `exists()`, and `list()` are boundary-checked, but they cannot pin a later operation to the same filesystem object. Use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()` for operations that must be race-resistant at the point of use.
161
174
 
@@ -358,7 +371,7 @@ for (const file of scan.entries) {
358
371
  }
359
372
  ```
360
373
 
361
- 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.
362
375
 
363
376
  ## Archive extraction
364
377
 
@@ -428,12 +441,12 @@ if (err instanceof FsSafeError) {
428
441
  }
429
442
  ```
430
443
 
431
- 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`.
432
445
 
433
446
  ## Safety model
434
447
 
435
448
  - root-bounded APIs resolve paths against a configured root and reject canonical escapes
436
- - 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
437
450
  - writes use pinned parent-directory helpers and atomic replacement on POSIX, with verified post-write identity
438
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
439
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,11 @@
1
+ export type DenyMutationPolicy = {
2
+ paths?: readonly string[];
3
+ prefixes?: readonly string[];
4
+ };
5
+ type DenyMutationCheckOptions = {
6
+ protectAncestors?: boolean;
7
+ };
8
+ export declare function assertMutationNotDenied(filePath: string, policy: DenyMutationPolicy | undefined, options?: DenyMutationCheckOptions): Promise<void>;
9
+ export declare function mergeDenyMutationPolicies(defaultPolicy: DenyMutationPolicy | undefined, callPolicy: DenyMutationPolicy | undefined): DenyMutationPolicy | undefined;
10
+ export {};
11
+ //# sourceMappingURL=deny-mutations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deny-mutations.d.ts","sourceRoot":"","sources":["../src/deny-mutations.ts"],"names":[],"mappings":"AAmDA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B,CAAC;AAEF,KAAK,wBAAwB,GAAG;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAqBF,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GAAG,SAAS,EACtC,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,IAAI,CAAC,CAiCf;AAED,wBAAgB,yBAAyB,CACvC,aAAa,EAAE,kBAAkB,GAAG,SAAS,EAC7C,UAAU,EAAE,kBAAkB,GAAG,SAAS,GACzC,kBAAkB,GAAG,SAAS,CAWhC"}
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js";
5
+ async function pathExists(filePath) {
6
+ try {
7
+ await fs.lstat(filePath);
8
+ return true;
9
+ }
10
+ catch (err) {
11
+ if (!isNotFoundPathError(err)) {
12
+ throw err;
13
+ }
14
+ return false;
15
+ }
16
+ }
17
+ async function resolvePathViaExistingAncestor(targetPath) {
18
+ const normalized = path.resolve(targetPath);
19
+ let cursor = normalized;
20
+ const missingSuffix = [];
21
+ while (path.dirname(cursor) !== cursor && !(await pathExists(cursor))) {
22
+ missingSuffix.unshift(path.basename(cursor));
23
+ cursor = path.dirname(cursor);
24
+ }
25
+ if (!(await pathExists(cursor))) {
26
+ return normalized;
27
+ }
28
+ try {
29
+ const resolvedAncestor = path.resolve(await fs.realpath(cursor));
30
+ return missingSuffix.length === 0
31
+ ? resolvedAncestor
32
+ : path.resolve(resolvedAncestor, ...missingSuffix);
33
+ }
34
+ catch {
35
+ return normalized;
36
+ }
37
+ }
38
+ async function comparablePaths(rawPath) {
39
+ assertNoNulPathInput(rawPath, "path contains a NUL byte");
40
+ const resolved = path.resolve(rawPath);
41
+ return new Set([resolved, await resolvePathViaExistingAncestor(resolved)]);
42
+ }
43
+ function isSamePath(left, right) {
44
+ return isPathInside(left, right) && isPathInside(right, left);
45
+ }
46
+ function hasPolicyEntries(policy) {
47
+ return Boolean(policy?.paths?.length || policy?.prefixes?.length);
48
+ }
49
+ function policyPathEntries(entries) {
50
+ const paths = [];
51
+ for (const entry of entries ?? []) {
52
+ if (entry.length === 0) {
53
+ throw new FsSafeError("invalid-path", "deny mutation paths must be non-empty");
54
+ }
55
+ assertNoNulPathInput(entry, "deny mutation path contains a NUL byte");
56
+ if (!path.isAbsolute(entry)) {
57
+ throw new FsSafeError("invalid-path", "deny mutation paths must be absolute");
58
+ }
59
+ paths.push(entry);
60
+ }
61
+ return paths;
62
+ }
63
+ export async function assertMutationNotDenied(filePath, policy, options = {}) {
64
+ if (!hasPolicyEntries(policy)) {
65
+ return;
66
+ }
67
+ const targetPaths = await comparablePaths(filePath);
68
+ for (const deniedPath of policyPathEntries(policy.paths)) {
69
+ const deniedPaths = await comparablePaths(deniedPath);
70
+ for (const target of targetPaths) {
71
+ for (const denied of deniedPaths) {
72
+ if (isSamePath(denied, target) ||
73
+ (options.protectAncestors === true && isPathInside(target, denied))) {
74
+ throw new FsSafeError("denied-path", "path is denied by denyMutations policy");
75
+ }
76
+ }
77
+ }
78
+ }
79
+ for (const deniedPrefix of policyPathEntries(policy.prefixes)) {
80
+ const deniedPaths = await comparablePaths(deniedPrefix);
81
+ for (const target of targetPaths) {
82
+ for (const denied of deniedPaths) {
83
+ if (isPathInside(denied, target) ||
84
+ (options.protectAncestors === true && isPathInside(target, denied))) {
85
+ throw new FsSafeError("denied-path", "path is denied by denyMutations policy");
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ export function mergeDenyMutationPolicies(defaultPolicy, callPolicy) {
92
+ if (!defaultPolicy) {
93
+ return callPolicy;
94
+ }
95
+ if (!callPolicy) {
96
+ return defaultPolicy;
97
+ }
98
+ return {
99
+ paths: [...(defaultPolicy.paths ?? []), ...(callPolicy.paths ?? [])],
100
+ prefixes: [...(defaultPolicy.prefixes ?? []), ...(callPolicy.prefixes ?? [])],
101
+ };
102
+ }
@@ -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" | "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,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
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { FsSafeError, categorizeFsSafeError, type FsSafeErrorCategory, type FsSafeErrorCode, } from "./errors.js";
2
- export { DEFAULT_ROOT_MAX_BYTES, root, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
2
+ export { DEFAULT_ROOT_MAX_BYTES, root, type DenyMutationPolicy, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
3
3
  export { configureFsSafePython, getFsSafePythonConfig, type FsSafePythonConfig, type FsSafePythonMode, } from "./pinned-python-config.js";
4
4
  export { writeExternalFileWithinRoot, type ExternalFileWriteOptions, type ExternalFileWriteResult, } from "./output.js";
5
5
  export { configureFsSafeLocks, getFsSafeLockConfig, type FsSafeLockConfig, } from "./lock-config.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"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"}
package/dist/json.js CHANGED
@@ -1,10 +1,49 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fsSync from "node:fs";
3
3
  import path from "node:path";
4
+ import { FsSafeError } from "./errors.js";
4
5
  import { stringifyJsonDocument } from "./json-stringify.js";
5
- import { readRegularFile, readRegularFileSync } from "./regular-file.js";
6
+ import { readRegularFile, readRegularFileSync, statRegularFile } from "./regular-file.js";
6
7
  import { openRootFileSync } from "./root-file.js";
7
8
  import { writeTextAtomic } from "./text-atomic.js";
9
+ const READ_RETRY_MAX_ATTEMPTS = 5;
10
+ const READ_RETRY_BASE_DELAY_MS = 50;
11
+ function isRetryableReadError(err, options) {
12
+ if (err instanceof FsSafeError && err.code === "path-mismatch") {
13
+ return true;
14
+ }
15
+ if (options.retryOpenRaceErrors !== true) {
16
+ return false;
17
+ }
18
+ const code = getErrorCode(err);
19
+ return code === "ENOENT" || code === "EPERM";
20
+ }
21
+ function sleep(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+ async function readRegularFileWithRetry(filePath, options = {}) {
25
+ let lastErr;
26
+ for (let attempt = 0; attempt < READ_RETRY_MAX_ATTEMPTS; attempt++) {
27
+ try {
28
+ return (await readRegularFile({ filePath })).buffer;
29
+ }
30
+ catch (err) {
31
+ lastErr = err;
32
+ if (!isRetryableReadError(err, options) || attempt === READ_RETRY_MAX_ATTEMPTS - 1) {
33
+ throw err;
34
+ }
35
+ await sleep(READ_RETRY_BASE_DELAY_MS * Math.pow(2, attempt));
36
+ }
37
+ }
38
+ throw lastErr;
39
+ }
40
+ async function readRegularFileIfExistsWithRetry(filePath) {
41
+ const initial = await statRegularFile(filePath);
42
+ if (initial.missing) {
43
+ return null;
44
+ }
45
+ return await readRegularFileWithRetry(filePath, { retryOpenRaceErrors: true });
46
+ }
8
47
  const JSON_FILE_MODE = 0o600;
9
48
  const JSON_DIR_MODE = 0o700;
10
49
  const SUPPORTS_SYNC_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants;
@@ -200,7 +239,11 @@ export function readRootJsonObjectSync(options) {
200
239
  }
201
240
  export async function tryReadJson(filePath) {
202
241
  try {
203
- const raw = (await readRegularFile({ filePath })).buffer.toString("utf8");
242
+ const buffer = await readRegularFileIfExistsWithRetry(filePath);
243
+ if (buffer === null) {
244
+ return null;
245
+ }
246
+ const raw = buffer.toString("utf8");
204
247
  return JSON.parse(raw);
205
248
  }
206
249
  catch {
@@ -210,7 +253,7 @@ export async function tryReadJson(filePath) {
210
253
  export async function readJson(filePath) {
211
254
  let raw;
212
255
  try {
213
- raw = (await readRegularFile({ filePath })).buffer.toString("utf8");
256
+ raw = (await readRegularFileWithRetry(filePath, { retryOpenRaceErrors: true })).toString("utf8");
214
257
  }
215
258
  catch (err) {
216
259
  throw new JsonFileReadError(filePath, "read", err);
@@ -225,7 +268,11 @@ export async function readJson(filePath) {
225
268
  export async function readJsonIfExists(filePath) {
226
269
  let raw;
227
270
  try {
228
- raw = (await readRegularFile({ filePath })).buffer.toString("utf8");
271
+ const buffer = await readRegularFileIfExistsWithRetry(filePath);
272
+ if (buffer === null) {
273
+ return null;
274
+ }
275
+ raw = buffer.toString("utf8");
229
276
  }
230
277
  catch (err) {
231
278
  if (getErrorCode(err) === "ENOENT") {
@@ -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
  }
@@ -0,0 +1,3 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+ export declare function resolveOpenedFileRealPathForHandle(handle: FileHandle, ioPath: string): Promise<string>;
3
+ //# sourceMappingURL=opened-realpath.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opened-realpath.d.ts","sourceRoot":"","sources":["../src/opened-realpath.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD,wBAAsB,kCAAkC,CACtD,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC,CAoCjB"}