@openclaw/fs-safe 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +6 -4
  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/device-path.d.ts +13 -0
  7. package/dist/device-path.d.ts.map +1 -0
  8. package/dist/device-path.js +112 -0
  9. package/dist/errors.d.ts +1 -1
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/file-identity.d.ts +1 -0
  12. package/dist/file-identity.d.ts.map +1 -1
  13. package/dist/file-identity.js +10 -2
  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/move-path.d.ts +3 -0
  20. package/dist/move-path.d.ts.map +1 -1
  21. package/dist/move-path.js +13 -1
  22. package/dist/path.d.ts +1 -0
  23. package/dist/path.d.ts.map +1 -1
  24. package/dist/path.js +1 -0
  25. package/dist/pinned-open.d.ts.map +1 -1
  26. package/dist/pinned-open.js +7 -0
  27. package/dist/pinned-write.d.ts +9 -1
  28. package/dist/pinned-write.d.ts.map +1 -1
  29. package/dist/pinned-write.js +80 -4
  30. package/dist/regular-file.d.ts.map +1 -1
  31. package/dist/regular-file.js +3 -0
  32. package/dist/root-impl.d.ts +4 -1
  33. package/dist/root-impl.d.ts.map +1 -1
  34. package/dist/root-impl.js +53 -47
  35. package/dist/root.d.ts +1 -1
  36. package/dist/root.d.ts.map +1 -1
  37. package/dist/secure-file.d.ts.map +1 -1
  38. package/dist/secure-file.js +2 -0
  39. package/dist/test-hooks.d.ts +1 -0
  40. package/dist/test-hooks.d.ts.map +1 -1
  41. package/dist/walk.d.ts +13 -2
  42. package/dist/walk.d.ts.map +1 -1
  43. package/dist/walk.js +29 -6
  44. package/docs/contributing.md +1 -1
  45. package/docs/errors.md +3 -0
  46. package/docs/install.md +3 -3
  47. package/docs/path.md +13 -0
  48. package/docs/reading.md +6 -4
  49. package/docs/root.md +2 -1
  50. package/docs/security-model.md +4 -2
  51. package/docs/types.md +2 -1
  52. package/docs/walk.md +11 -1
  53. package/docs/writing.md +23 -3
  54. package/package.json +23 -17
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 - 2026-07-01
4
+
5
+ ### Security and Correctness
6
+
7
+ - Update the optional TAR extractor to reject NUL-terminated PAX values, invalid negative entry sizes, and explosive decompression payloads.
8
+
9
+ ### Compatibility
10
+
11
+ - Add explicit `renameIdentity: "verify-content-with-lock"` writes for rclone-style FUSE mounts whose inode identity changes across rename, while keeping strict identity verification as the default and failing closed on stale cooperative locks. (#32, #33; thanks @jlautman)
12
+
13
+ ## 0.4.0 - 2026-06-17
14
+
15
+ ### Features
16
+
17
+ - 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)
18
+
19
+ ### Compatibility
20
+
21
+ - Require Node.js 22 or newer for the npm package and docs, matching the maintained CI matrix.
22
+
23
+ ### Security and Correctness
24
+
25
+ - 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)
26
+ - 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)
27
+ - 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.
28
+
3
29
  ## 0.3.0 - 2026-05-21
4
30
 
5
31
  ### 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"}
@@ -2,5 +2,6 @@ export type FileIdentityStat = {
2
2
  dev: number | bigint;
3
3
  ino: number | bigint;
4
4
  };
5
+ export declare function sha256Hex(data: string | Buffer, encoding?: BufferEncoding): string;
5
6
  export declare function sameFileIdentity(left: FileIdentityStat, right: FileIdentityStat, platform?: NodeJS.Platform): boolean;
6
7
  //# sourceMappingURL=file-identity.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-identity.d.ts","sourceRoot":"","sources":["../src/file-identity.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;CACtB,CAAC;AAMF,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,gBAAgB,EACtB,KAAK,EAAE,gBAAgB,EACvB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAWT"}
1
+ {"version":3,"file":"file-identity.d.ts","sourceRoot":"","sources":["../src/file-identity.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;CACtB,CAAC;AAUF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,QAAQ,CAAC,EAAE,cAAc,GAAG,MAAM,CAGlF;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,gBAAgB,EACtB,KAAK,EAAE,gBAAgB,EACvB,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,OAAO,CAWT"}
@@ -1,13 +1,21 @@
1
+ import { createHash } from "node:crypto";
1
2
  function isZero(value) {
2
3
  return value === 0 || value === 0n;
3
4
  }
5
+ function sameStatValue(left, right) {
6
+ return typeof left === typeof right ? left === right : BigInt(left) === BigInt(right);
7
+ }
8
+ export function sha256Hex(data, encoding) {
9
+ const buffer = typeof data === "string" ? Buffer.from(data, encoding ?? "utf8") : data;
10
+ return createHash("sha256").update(buffer).digest("hex");
11
+ }
4
12
  export function sameFileIdentity(left, right, platform = process.platform) {
5
- if (left.ino !== right.ino) {
13
+ if (!sameStatValue(left.ino, right.ino)) {
6
14
  return false;
7
15
  }
8
16
  // On Windows, path-based stat calls can report dev=0 while fd-based stat
9
17
  // reports a real volume serial; treat either-side dev=0 as "unknown device".
10
- if (left.dev === right.dev) {
18
+ if (sameStatValue(left.dev, right.dev)) {
11
19
  return true;
12
20
  }
13
21
  return platform === "win32" && (isZero(left.dev) || isZero(right.dev));
@@ -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 DenyMutationPolicy, type HardlinkPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
2
+ export { DEFAULT_ROOT_MAX_BYTES, root, type DenyMutationPolicy, type HardlinkPolicy, type RenameIdentityPolicy, type OpenResult, type ReadResult, type Root, type RootAppendOptions, type RootCopyOptions, type RootCreateJsonOptions, type RootCreateOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions, type SymlinkPolicy, type WritableOpenMode, type WritableOpenResult, } from "./root.js";
3
3
  export { configureFsSafePython, getFsSafePythonConfig, type FsSafePythonConfig, type FsSafePythonMode, } from "./pinned-python-config.js";
4
4
  export { writeExternalFileWithinRoot, type ExternalFileWriteOptions, type ExternalFileWriteResult, } from "./output.js";
5
5
  export { configureFsSafeLocks, getFsSafeLockConfig, type FsSafeLockConfig, } from "./lock-config.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,sBAAsB,EACtB,IAAI,EACJ,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,IAAI,EACT,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,uBAAuB,EAC5B,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,gBAAgB,GACtB,MAAM,kBAAkB,CAAC"}
@@ -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" };
@@ -8,7 +8,9 @@ type PinnedWriteInput = {
8
8
  kind: "stream";
9
9
  stream: Readable;
10
10
  };
11
- export declare function runPinnedWriteHelper(params: {
11
+ type RenameIdentityMismatchPolicy = "throw" | "verify-content";
12
+ export type RenameIdentityPolicy = "strict" | "verify-content-with-lock";
13
+ type PinnedWriteParams = {
12
14
  rootPath: string;
13
15
  relativeParentPath: string;
14
16
  basename: string;
@@ -18,6 +20,12 @@ export declare function runPinnedWriteHelper(params: {
18
20
  maxBytes?: number;
19
21
  input: PinnedWriteInput;
20
22
  rootIdentity?: FileIdentityStat;
23
+ onRenameIdentityMismatch?: RenameIdentityMismatchPolicy;
24
+ };
25
+ export declare function runPinnedWriteHelper(params: PinnedWriteParams): Promise<FileIdentityStat>;
26
+ export declare function runPinnedWriteWithRenamePolicy(params: PinnedWriteParams & {
27
+ targetPath: string;
28
+ renameIdentity?: RenameIdentityPolicy;
21
29
  }): Promise<FileIdentityStat>;
22
30
  export declare function runPinnedCopyHelper(params: {
23
31
  rootPath: string;
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;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;AAa3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AA6EzC,KAAK,4BAA4B,GAAG,OAAO,GAAG,gBAAgB,CAAC;AAE/D,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,0BAA0B,CAAC;AAEzE,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;IACxB,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC,wBAAwB,CAAC,EAAE,4BAA4B,CAAC;CACzD,CAAC;AAEF,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAkD/F;AAED,wBAAsB,8BAA8B,CAClD,MAAM,EAAE,iBAAiB,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,oBAAoB,CAAC;CACvC,GACA,OAAO,CAAC,gBAAgB,CAAC,CA2B3B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;IACjC,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqB5B"}
@@ -4,11 +4,14 @@ 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 { sameFileIdentity } from "./file-identity.js";
7
+ import { syncDirectoryBestEffort } from "./fsync.js";
8
+ import { sameFileIdentity, sha256Hex } from "./file-identity.js";
8
9
  import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
9
10
  import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
10
11
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
11
12
  import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
13
+ import { withSidecarLock } from "./sidecar-lock.js";
14
+ import { getFsSafeTestHooks } from "./test-hooks.js";
12
15
  function byteLength(input, encoding) {
13
16
  return typeof input === "string"
14
17
  ? Buffer.byteLength(input, encoding ?? "utf8")
@@ -66,6 +69,12 @@ export async function runPinnedWriteHelper(params) {
66
69
  validatePinnedOperationPayload({
67
70
  relativeParentPath: params.relativeParentPath,
68
71
  });
72
+ // The Python helper deliberately enforces the strict post-rename inode
73
+ // contract. The explicit compatibility policy therefore uses the guarded
74
+ // Node fallback, where content verification can replace that one check.
75
+ if (params.onRenameIdentityMismatch === "verify-content") {
76
+ return await runPinnedWriteFallback(params);
77
+ }
69
78
  if (getFsSafePythonConfig().mode === "off") {
70
79
  return await runPinnedWriteFallback(params);
71
80
  }
@@ -80,8 +89,11 @@ export async function runPinnedWriteHelper(params) {
80
89
  throw error;
81
90
  }
82
91
  }
92
+ const input = params.input.kind === "stream"
93
+ ? { kind: "buffer", data: Buffer.from(await inputToBase64(params.input, params.maxBytes), "base64") }
94
+ : params.input;
83
95
  const payload = {
84
- base64: await inputToBase64(params.input, params.maxBytes),
96
+ base64: await inputToBase64(input, params.maxBytes),
85
97
  basename: params.basename,
86
98
  maxBytes: params.maxBytes ?? -1,
87
99
  mkdir: params.mkdir,
@@ -99,11 +111,32 @@ export async function runPinnedWriteHelper(params) {
99
111
  }
100
112
  catch (error) {
101
113
  if (canFallbackFromPythonError(error)) {
102
- return await runPinnedWriteFallback(params);
114
+ return await runPinnedWriteFallback({ ...params, input });
103
115
  }
104
116
  throw error;
105
117
  }
106
118
  }
119
+ export async function runPinnedWriteWithRenamePolicy(params) {
120
+ const { targetPath, renameIdentity, ...writeParams } = params;
121
+ if (renameIdentity !== "verify-content-with-lock") {
122
+ return await runPinnedWriteHelper(writeParams);
123
+ }
124
+ const relativeTargetPath = writeParams.relativeParentPath
125
+ ? `${writeParams.relativeParentPath}/${writeParams.basename}`
126
+ : writeParams.basename;
127
+ const lockPath = path.join(writeParams.rootPath, `.fs-safe-write-${sha256Hex(relativeTargetPath)}.lock`);
128
+ return await withSidecarLock(writeParams.rootPath, {
129
+ managerKey: `fs-safe.write:${targetPath}`,
130
+ lockPath,
131
+ staleMs: 30_000,
132
+ timeoutMs: 5_000,
133
+ payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
134
+ retry: { retries: 5, minTimeout: 100, maxTimeout: 2_000, factor: 2 },
135
+ }, async () => await runPinnedWriteHelper({
136
+ ...writeParams,
137
+ onRenameIdentityMismatch: "verify-content",
138
+ }));
139
+ }
107
140
  export async function runPinnedCopyHelper(params) {
108
141
  assertSafeBasename(params.basename);
109
142
  validatePinnedOperationPayload({
@@ -159,7 +192,10 @@ async function runPinnedWriteFallback(params) {
159
192
  else {
160
193
  await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
161
194
  }
195
+ await handle.sync();
162
196
  const stat = await handle.stat();
197
+ await handle.close().catch(() => undefined);
198
+ await syncDirectoryBestEffort(parentPath);
163
199
  created = false;
164
200
  return { dev: stat.dev, ino: stat.ino };
165
201
  }
@@ -201,15 +237,55 @@ async function runPinnedWriteFallback(params) {
201
237
  throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
202
238
  }
203
239
  const expectedTempStat = tempStat;
240
+ await handle.sync();
204
241
  await handle.close().catch(() => undefined);
205
242
  handle = undefined;
206
243
  await withAsyncDirectoryGuards([parentGuard], async () => {
207
244
  await fs.rename(tempPath, targetPath);
208
245
  renamed = true;
246
+ await getFsSafeTestHooks()?.afterPinnedWriteFallbackRename?.(targetPath);
247
+ await syncDirectoryBestEffort(parentPath);
209
248
  targetStat = await fs.lstat(targetPath);
210
- if (targetStat.isSymbolicLink() || !sameFileIdentity(targetStat, expectedTempStat)) {
249
+ if (targetStat.isSymbolicLink()) {
211
250
  throw new FsSafeError("path-mismatch", "fallback target changed during write");
212
251
  }
252
+ if (!sameFileIdentity(targetStat, expectedTempStat)) {
253
+ // On filesystems like rclone FUSE, rename(2) can give the destination a
254
+ // different inode from the source temp fd even with zero concurrency. The
255
+ // caller must ensure mutual exclusion before passing "verify-content";
256
+ // fall back to a content hash for this rename-boundary check only.
257
+ if (params.onRenameIdentityMismatch !== "verify-content") {
258
+ throw new FsSafeError("path-mismatch", "fallback target changed during write");
259
+ }
260
+ if (params.input.kind !== "buffer") {
261
+ throw new FsSafeError("path-mismatch", "fallback target changed during write");
262
+ }
263
+ const expectedHash = sha256Hex(params.input.data, params.input.encoding);
264
+ const readFlags = fsSync.constants.O_RDONLY |
265
+ (process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
266
+ ? fsSync.constants.O_NOFOLLOW
267
+ : 0);
268
+ const readHandle = await fs.open(targetPath, readFlags);
269
+ let actualHash;
270
+ let readHandleStat;
271
+ try {
272
+ // Capture fd-based identity before reading — this is stable across all
273
+ // subsequent lookups (on FUSE and locally), unlike the lstat-based
274
+ // targetStat that triggered this fallback.
275
+ readHandleStat = await readHandle.stat();
276
+ actualHash = sha256Hex(await readHandle.readFile());
277
+ }
278
+ finally {
279
+ await readHandle.close().catch(() => undefined);
280
+ }
281
+ if (actualHash !== expectedHash) {
282
+ throw new FsSafeError("path-mismatch", "fallback target changed during write");
283
+ }
284
+ // Replace the unreliable lstat-based targetStat with the fd-based stat so
285
+ // the returned identity is consistent with what subsequent verifications
286
+ // (e.g. verifyAtomicWriteResult) will obtain by opening the same file.
287
+ targetStat = readHandleStat;
288
+ }
213
289
  });
214
290
  }
215
291
  catch (error) {