From 943b8f171a78fda44ba065dcec29da27b8c072c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:32:30 +0000 Subject: [PATCH] fix: align windows safe-open file identity checks --- CHANGELOG.md | 1 + src/infra/file-identity.test.ts | 33 +++++++++++++++++++++++++++++++++ src/infra/file-identity.ts | 25 +++++++++++++++++++++++++ src/infra/fs-safe.ts | 22 +++------------------- src/infra/safe-open-sync.ts | 8 ++------ 5 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 src/infra/file-identity.test.ts create mode 100644 src/infra/file-identity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd6ca5d44a..29963ba844b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. - macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. diff --git a/src/infra/file-identity.test.ts b/src/infra/file-identity.test.ts new file mode 100644 index 00000000000..12b3029cda1 --- /dev/null +++ b/src/infra/file-identity.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { sameFileIdentity, type FileIdentityStat } from "./file-identity.js"; + +function stat(dev: number | bigint, ino: number | bigint): FileIdentityStat { + return { dev, ino }; +} + +describe("sameFileIdentity", () => { + it("accepts exact dev+ino match", () => { + expect(sameFileIdentity(stat(7, 11), stat(7, 11), "linux")).toBe(true); + }); + + it("rejects inode mismatch", () => { + expect(sameFileIdentity(stat(7, 11), stat(7, 12), "linux")).toBe(false); + }); + + it("rejects dev mismatch on non-windows", () => { + expect(sameFileIdentity(stat(7, 11), stat(8, 11), "linux")).toBe(false); + }); + + it("accepts win32 dev mismatch when either side is 0", () => { + expect(sameFileIdentity(stat(0, 11), stat(8, 11), "win32")).toBe(true); + expect(sameFileIdentity(stat(7, 11), stat(0, 11), "win32")).toBe(true); + }); + + it("keeps dev strictness on win32 when both dev values are non-zero", () => { + expect(sameFileIdentity(stat(7, 11), stat(8, 11), "win32")).toBe(false); + }); + + it("handles bigint stats", () => { + expect(sameFileIdentity(stat(0n, 11n), stat(8n, 11n), "win32")).toBe(true); + }); +}); diff --git a/src/infra/file-identity.ts b/src/infra/file-identity.ts new file mode 100644 index 00000000000..686d6dd086e --- /dev/null +++ b/src/infra/file-identity.ts @@ -0,0 +1,25 @@ +export type FileIdentityStat = { + dev: number | bigint; + ino: number | bigint; +}; + +function isZero(value: number | bigint): boolean { + return value === 0 || value === 0n; +} + +export function sameFileIdentity( + left: FileIdentityStat, + right: FileIdentityStat, + platform: NodeJS.Platform = process.platform, +): boolean { + if (left.ino !== right.ino) { + return false; + } + + // On Windows, path-based stat calls can report dev=0 while fd-based stat + // reports a real volume serial; treat either-side dev=0 as "unknown device". + if (left.dev === right.dev) { + return true; + } + return platform === "win32" && (isZero(left.dev) || isZero(right.dev)); +} diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 49604548a81..b42a109df98 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; +import { sameFileIdentity } from "./file-identity.js"; import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; export type SafeOpenErrorCode = @@ -40,23 +41,6 @@ const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants. const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); -/** - * Compare file stats for identity verification. - * On Windows, device IDs (dev) are unreliable and may differ between - * handle.stat() and fs.lstat() for the same file. We skip dev comparison - * on Windows and rely solely on inode (ino) matching. - */ -function statsMatch(stat1: Stats, stat2: Stats): boolean { - if (stat1.ino !== stat2.ino) { - return false; - } - // On Windows, dev values are unreliable across different stat sources - if (process.platform !== "win32" && stat1.dev !== stat2.dev) { - return false; - } - return true; -} - async function openVerifiedLocalFile(filePath: string): Promise { let handle: FileHandle; try { @@ -79,13 +63,13 @@ async function openVerifiedLocalFile(filePath: string): Promise if (!stat.isFile()) { throw new SafeOpenError("not-file", "not a file"); } - if (!statsMatch(stat, lstat)) { + if (!sameFileIdentity(stat, lstat)) { throw new SafeOpenError("path-mismatch", "path changed during read"); } const realPath = await fs.realpath(filePath); const realStat = await fs.stat(realPath); - if (!statsMatch(stat, realStat)) { + if (!sameFileIdentity(stat, realStat)) { throw new SafeOpenError("path-mismatch", "path mismatch"); } diff --git a/src/infra/safe-open-sync.ts b/src/infra/safe-open-sync.ts index f2dbdfb703b..311849ba9fd 100644 --- a/src/infra/safe-open-sync.ts +++ b/src/infra/safe-open-sync.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { sameFileIdentity as hasSameFileIdentity } from "./file-identity.js"; export type SafeOpenSyncFailureReason = "path" | "validation" | "io"; @@ -17,12 +18,7 @@ function isExpectedPathError(error: unknown): boolean { } export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - // On Windows, lstatSync (by path) may return dev=0 while fstatSync (by fd) - // returns the real volume serial number. When either dev is 0, fall back to - // ino-only comparison which is still unique within a single volume. - const devMatch = - left.dev === right.dev || (process.platform === "win32" && (left.dev === 0 || right.dev === 0)); - return devMatch && left.ino === right.ino; + return hasSameFileIdentity(left, right); } export function openVerifiedFileSync(params: {