fix: align windows safe-open file identity checks

This commit is contained in:
Peter Steinberger
2026-02-25 00:32:30 +00:00
parent 7455ceecf8
commit 943b8f171a
5 changed files with 64 additions and 25 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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<SafeOpenResult> {
let handle: FileHandle;
try {
@@ -79,13 +63,13 @@ async function openVerifiedLocalFile(filePath: string): Promise<SafeOpenResult>
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");
}

View File

@@ -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: {