mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 12:50:42 +00:00
557 lines
21 KiB
TypeScript
557 lines
21 KiB
TypeScript
import fs from "node:fs";
|
|
import fsPromises from "node:fs/promises";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { writeFileWithinRoot } from "openclaw/plugin-sdk/file-access-runtime";
|
|
import type {
|
|
SandboxFsBridge,
|
|
SandboxFsStat,
|
|
SandboxResolvedPath,
|
|
} from "openclaw/plugin-sdk/sandbox";
|
|
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
|
|
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
|
|
import { movePathWithCopyFallback } from "./mirror.js";
|
|
|
|
type ResolvedMountPath = SandboxResolvedPath & {
|
|
mountHostRoot: string;
|
|
writable: boolean;
|
|
source: "workspace" | "agent";
|
|
};
|
|
|
|
export function createOpenShellFsBridge(params: {
|
|
sandbox: OpenShellFsBridgeContext;
|
|
backend: OpenShellSandboxBackend;
|
|
}): SandboxFsBridge {
|
|
return new OpenShellFsBridge(params.sandbox, params.backend);
|
|
}
|
|
|
|
class OpenShellFsBridge implements SandboxFsBridge {
|
|
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
|
|
(target) => this.resolveTarget(target),
|
|
(target, action) => this.ensureWritable(target, action),
|
|
);
|
|
|
|
constructor(
|
|
private readonly sandbox: OpenShellFsBridgeContext,
|
|
private readonly backend: OpenShellSandboxBackend,
|
|
) {}
|
|
|
|
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
|
|
const target = this.resolveTarget(params);
|
|
return {
|
|
hostPath: target.hostPath,
|
|
relativePath: target.relativePath,
|
|
containerPath: target.containerPath,
|
|
};
|
|
}
|
|
|
|
async readFile(params: {
|
|
filePath: string;
|
|
cwd?: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<Buffer> {
|
|
const target = this.resolveTarget(params);
|
|
const hostPath = this.requireHostPath(target);
|
|
const handle = await openPinnedReadableFile({
|
|
absolutePath: hostPath,
|
|
rootPath: target.mountHostRoot,
|
|
containerPath: target.containerPath,
|
|
});
|
|
try {
|
|
return (await handle.readFile()) as Buffer;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async writeFile(params: {
|
|
filePath: string;
|
|
cwd?: string;
|
|
data: Buffer | string;
|
|
encoding?: BufferEncoding;
|
|
mkdir?: boolean;
|
|
signal?: AbortSignal;
|
|
}): Promise<void> {
|
|
const target = this.resolveTarget(params);
|
|
const hostPath = this.requireHostPath(target);
|
|
this.ensureWritable(target, "write files");
|
|
await assertLocalPathSafety({
|
|
target,
|
|
root: target.mountHostRoot,
|
|
allowMissingLeaf: true,
|
|
allowFinalSymlinkForUnlink: false,
|
|
});
|
|
const buffer = Buffer.isBuffer(params.data)
|
|
? params.data
|
|
: Buffer.from(params.data, params.encoding ?? "utf8");
|
|
await writeFileWithinRoot({
|
|
rootDir: target.mountHostRoot,
|
|
relativePath: path.relative(target.mountHostRoot, hostPath),
|
|
data: buffer,
|
|
mkdir: params.mkdir,
|
|
});
|
|
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
|
|
}
|
|
|
|
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
|
|
const target = this.resolveTarget(params);
|
|
const hostPath = this.requireHostPath(target);
|
|
this.ensureWritable(target, "create directories");
|
|
await assertLocalPathSafety({
|
|
target,
|
|
root: target.mountHostRoot,
|
|
allowMissingLeaf: true,
|
|
allowFinalSymlinkForUnlink: false,
|
|
});
|
|
await fsPromises.mkdir(hostPath, { recursive: true });
|
|
await this.backend.runRemoteShellScript({
|
|
script: 'mkdir -p -- "$1"',
|
|
args: [target.containerPath],
|
|
signal: params.signal,
|
|
});
|
|
}
|
|
|
|
async remove(params: {
|
|
filePath: string;
|
|
cwd?: string;
|
|
recursive?: boolean;
|
|
force?: boolean;
|
|
signal?: AbortSignal;
|
|
}): Promise<void> {
|
|
const target = this.resolveTarget(params);
|
|
const hostPath = this.requireHostPath(target);
|
|
this.ensureWritable(target, "remove files");
|
|
await assertLocalPathSafety({
|
|
target,
|
|
root: target.mountHostRoot,
|
|
allowMissingLeaf: params.force !== false,
|
|
allowFinalSymlinkForUnlink: true,
|
|
});
|
|
await fsPromises.rm(hostPath, {
|
|
recursive: params.recursive ?? false,
|
|
force: params.force !== false,
|
|
});
|
|
await this.backend.runRemoteShellScript({
|
|
script: params.recursive
|
|
? 'rm -rf -- "$1"'
|
|
: 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi',
|
|
args: [target.containerPath],
|
|
signal: params.signal,
|
|
allowFailure: params.force !== false,
|
|
});
|
|
}
|
|
|
|
async rename(params: {
|
|
from: string;
|
|
to: string;
|
|
cwd?: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<void> {
|
|
const { from, to } = this.resolveRenameTargets(params);
|
|
const fromHostPath = this.requireHostPath(from);
|
|
const toHostPath = this.requireHostPath(to);
|
|
await assertLocalPathSafety({
|
|
target: from,
|
|
root: from.mountHostRoot,
|
|
allowMissingLeaf: false,
|
|
allowFinalSymlinkForUnlink: true,
|
|
});
|
|
await assertLocalPathSafety({
|
|
target: to,
|
|
root: to.mountHostRoot,
|
|
allowMissingLeaf: true,
|
|
allowFinalSymlinkForUnlink: false,
|
|
});
|
|
await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true });
|
|
await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath });
|
|
await this.backend.runRemoteShellScript({
|
|
script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"',
|
|
args: [from.containerPath, to.containerPath],
|
|
signal: params.signal,
|
|
});
|
|
}
|
|
|
|
async stat(params: {
|
|
filePath: string;
|
|
cwd?: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<SandboxFsStat | null> {
|
|
const target = this.resolveTarget(params);
|
|
const hostPath = this.requireHostPath(target);
|
|
const stats = await fsPromises.lstat(hostPath).catch(() => null);
|
|
if (!stats) {
|
|
return null;
|
|
}
|
|
await assertLocalPathSafety({
|
|
target,
|
|
root: target.mountHostRoot,
|
|
allowMissingLeaf: false,
|
|
allowFinalSymlinkForUnlink: false,
|
|
});
|
|
return {
|
|
type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
|
size: stats.size,
|
|
mtimeMs: stats.mtimeMs,
|
|
};
|
|
}
|
|
|
|
private ensureWritable(target: ResolvedMountPath, action: string) {
|
|
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
|
|
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
|
|
}
|
|
}
|
|
|
|
private requireHostPath(target: ResolvedMountPath): string {
|
|
if (!target.hostPath) {
|
|
throw new Error(
|
|
`OpenShell mirror bridge requires a local host path: ${target.containerPath}`,
|
|
);
|
|
}
|
|
return target.hostPath;
|
|
}
|
|
|
|
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath {
|
|
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
|
|
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
|
|
const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot;
|
|
const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace(
|
|
/\\/g,
|
|
"/",
|
|
);
|
|
const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
|
|
const input = params.filePath.trim();
|
|
|
|
if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
|
|
const relative = path.posix.relative(workspaceContainerRoot, input) || "";
|
|
const hostPath = relative
|
|
? path.resolve(workspaceRoot, ...relative.split("/"))
|
|
: workspaceRoot;
|
|
return {
|
|
hostPath,
|
|
relativePath: relative,
|
|
containerPath: relative
|
|
? path.posix.join(workspaceContainerRoot, relative)
|
|
: workspaceContainerRoot,
|
|
mountHostRoot: workspaceRoot,
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "workspace",
|
|
};
|
|
}
|
|
|
|
if (
|
|
hasAgentMount &&
|
|
(input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)
|
|
) {
|
|
const relative = path.posix.relative(agentContainerRoot, input) || "";
|
|
const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot;
|
|
return {
|
|
hostPath,
|
|
relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
|
|
containerPath: relative
|
|
? path.posix.join(agentContainerRoot, relative)
|
|
: agentContainerRoot,
|
|
mountHostRoot: agentRoot,
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "agent",
|
|
};
|
|
}
|
|
|
|
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
|
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
|
|
|
if (isPathInside(workspaceRoot, hostPath)) {
|
|
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
return {
|
|
hostPath,
|
|
relativePath: relative,
|
|
containerPath: relative
|
|
? path.posix.join(workspaceContainerRoot, relative)
|
|
: workspaceContainerRoot,
|
|
mountHostRoot: workspaceRoot,
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "workspace",
|
|
};
|
|
}
|
|
|
|
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
|
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
return {
|
|
hostPath,
|
|
relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
|
|
containerPath: relative
|
|
? path.posix.join(agentContainerRoot, relative)
|
|
: agentContainerRoot,
|
|
mountHostRoot: agentRoot,
|
|
writable: this.sandbox.workspaceAccess === "rw",
|
|
source: "agent",
|
|
};
|
|
}
|
|
|
|
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
|
}
|
|
}
|
|
|
|
function isPathInside(root: string, target: string): boolean {
|
|
const relative = path.relative(root, target);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
async function assertLocalPathSafety(params: {
|
|
target: ResolvedMountPath;
|
|
root: string;
|
|
allowMissingLeaf: boolean;
|
|
allowFinalSymlinkForUnlink: boolean;
|
|
}): Promise<void> {
|
|
if (!params.target.hostPath) {
|
|
throw new Error(`Missing local host path for ${params.target.containerPath}`);
|
|
}
|
|
const canonicalRoot = await fsPromises
|
|
.realpath(params.root)
|
|
.catch(() => path.resolve(params.root));
|
|
const candidate = await resolveCanonicalCandidate(params.target.hostPath);
|
|
if (!isPathInside(canonicalRoot, candidate)) {
|
|
throw new Error(
|
|
`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,
|
|
);
|
|
}
|
|
|
|
const relative = path.relative(params.root, params.target.hostPath);
|
|
const segments = relative
|
|
.split(path.sep)
|
|
.filter(Boolean)
|
|
.slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
|
|
let cursor = params.root;
|
|
for (let index = 0; index < segments.length; index += 1) {
|
|
cursor = path.join(cursor, segments[index]);
|
|
const stats = await fsPromises.lstat(cursor).catch(() => null);
|
|
if (!stats) {
|
|
if (index === segments.length - 1 && params.allowMissingLeaf) {
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
const isFinal = index === segments.length - 1;
|
|
if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) {
|
|
throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function resolveCanonicalCandidate(targetPath: string): Promise<string> {
|
|
const missing: string[] = [];
|
|
let cursor = path.resolve(targetPath);
|
|
while (true) {
|
|
const exists = await fsPromises
|
|
.lstat(cursor)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (exists) {
|
|
const canonical = await fsPromises.realpath(cursor).catch(() => cursor);
|
|
return path.resolve(canonical, ...missing);
|
|
}
|
|
const parent = path.dirname(cursor);
|
|
if (parent === cursor) {
|
|
return path.resolve(cursor, ...missing);
|
|
}
|
|
missing.unshift(path.basename(cursor));
|
|
cursor = parent;
|
|
}
|
|
}
|
|
|
|
async function openPinnedReadableFile(params: {
|
|
absolutePath: string;
|
|
rootPath: string;
|
|
containerPath: string;
|
|
}): Promise<FileHandle> {
|
|
// The literal root is what `resolveTarget` joins caller-provided relative
|
|
// paths against, so pre-open containment must be checked in literal form.
|
|
// The canonical root is derived separately and used for the post-open
|
|
// path checks (fd-path readlink and realpath cross-check), so a workspace
|
|
// that is itself configured as a symlink still works.
|
|
const literalRoot = path.resolve(params.rootPath);
|
|
const canonicalRoot = await fsPromises.realpath(literalRoot).catch(() => literalRoot);
|
|
const literalPath = path.resolve(params.absolutePath);
|
|
// Cheap string-prefix check on the caller-provided absolute path; no
|
|
// filesystem state is read here, so there is no TOCTOU window. Deeper
|
|
// checks run after the fd is pinned.
|
|
if (!isPathInside(literalRoot, literalPath)) {
|
|
throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`);
|
|
}
|
|
const { flags: openReadFlags, supportsNoFollow } = resolveOpenReadFlags();
|
|
// Open first so every later check runs against an fd that is already pinned
|
|
// to one specific inode. `O_NOFOLLOW` prevents the final path component from
|
|
// being a symlink; the ancestor walk below handles parent-directory symlink
|
|
// swaps on platforms where fd-path readlink is not available.
|
|
const handle = await fsPromises.open(literalPath, openReadFlags);
|
|
try {
|
|
const openedStat = await handle.stat();
|
|
if (!openedStat.isFile()) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
|
|
}
|
|
if (openedStat.nlink > 1) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
|
|
}
|
|
const resolvedPath = await resolveOpenedReadablePath(handle.fd);
|
|
if (resolvedPath !== null) {
|
|
// Primary guarantee on Linux: the fd's resolved path is derived from the
|
|
// kernel, so a parent-directory swap cannot make this return a stale path.
|
|
if (!isPathInside(canonicalRoot, resolvedPath)) {
|
|
throw new Error(
|
|
`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`,
|
|
);
|
|
}
|
|
return handle;
|
|
}
|
|
// Fallback for platforms where fd-path readlink is unavailable. On macOS,
|
|
// `/dev/fd/N` is a character device so readlink returns EINVAL; on Windows
|
|
// there is no `/proc` equivalent. With no kernel-backed path readback we
|
|
// must prove the pinned fd is in-root without trusting a separate
|
|
// `realpath` + `lstat` pair that would race between the two awaits. Walk
|
|
// every ancestor between `literalRoot` and `literalPath` — the actual
|
|
// on-disk chain — and reject if any ancestor is a symlink, then use a
|
|
// single `stat` call to confirm that the path still resolves to the
|
|
// same file the fd has pinned. `fs.promises.stat` resolves the path and
|
|
// returns the final file's identity in one syscall, so there is no
|
|
// between-await window for an attacker to race.
|
|
await assertAncestorChainHasNoSymlinks(literalRoot, literalPath, params.containerPath, {
|
|
// On platforms where `O_NOFOLLOW` is unavailable (Windows), the open
|
|
// call would have transparently followed a final-component symlink, so
|
|
// the ancestor walk has to lstat the leaf as well.
|
|
includeLeaf: !supportsNoFollow,
|
|
});
|
|
const currentResolvedStat = await fsPromises.stat(literalPath);
|
|
if (!sameFileIdentity(currentResolvedStat, openedStat)) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
|
|
}
|
|
// Belt-and-suspenders: re-fstat the pinned fd after the identity check and
|
|
// confirm the file type and link count are still trustworthy. A hardlink
|
|
// that appeared between the initial fstat and here is not exploitable for
|
|
// the read (the fd is already pinned to the original inode), but failing
|
|
// closed here keeps the guarantee simple: the bytes we return always come
|
|
// from a file that was a single-linked regular file at verification time.
|
|
const postCheckStat = await handle.stat();
|
|
if (!postCheckStat.isFile() || postCheckStat.nlink > 1) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${params.containerPath}`);
|
|
}
|
|
return handle;
|
|
} catch (error) {
|
|
await handle.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Walks each directory between canonicalRoot (exclusive) and
|
|
// targetAbsolutePath, `lstat`'ing each segment. Rejects if any intermediate
|
|
// segment is a symlink or a non-directory. By default the final component is
|
|
// not walked because `O_NOFOLLOW` already protects it on the open call. Pass
|
|
// `includeLeaf: true` on platforms where `O_NOFOLLOW` is unavailable
|
|
// (Windows) so a symlinked leaf cannot be followed silently by `open`.
|
|
async function assertAncestorChainHasNoSymlinks(
|
|
canonicalRoot: string,
|
|
targetAbsolutePath: string,
|
|
containerPath: string,
|
|
options: { includeLeaf?: boolean } = {},
|
|
): Promise<void> {
|
|
const relative = path.relative(canonicalRoot, targetAbsolutePath);
|
|
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
return;
|
|
}
|
|
const segments = relative.split(path.sep).filter((segment) => segment.length > 0);
|
|
const lastIndex = options.includeLeaf ? segments.length : segments.length - 1;
|
|
let cursor = canonicalRoot;
|
|
for (let i = 0; i < lastIndex; i += 1) {
|
|
cursor = path.join(cursor, segments[i]);
|
|
const stat = await fsPromises.lstat(cursor).catch(() => null);
|
|
if (!stat) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
|
|
}
|
|
const isLeaf = i === segments.length - 1;
|
|
if (stat.isSymbolicLink()) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
|
|
}
|
|
if (!isLeaf && !stat.isDirectory()) {
|
|
throw new Error(`Sandbox boundary checks failed; cannot read files: ${containerPath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
type ReadOpenFlagsResolution = { flags: number; supportsNoFollow: boolean };
|
|
|
|
let readOpenFlagsResolverForTest: (() => ReadOpenFlagsResolution) | undefined;
|
|
|
|
function resolveOpenReadFlags(): ReadOpenFlagsResolution {
|
|
if (readOpenFlagsResolverForTest) {
|
|
return readOpenFlagsResolverForTest();
|
|
}
|
|
const closeOnExec = (fs.constants as Record<string, number>).O_CLOEXEC ?? 0;
|
|
const supportsNoFollow = typeof fs.constants.O_NOFOLLOW === "number";
|
|
const noFollow = supportsNoFollow ? fs.constants.O_NOFOLLOW : 0;
|
|
return {
|
|
flags: fs.constants.O_RDONLY | noFollow | closeOnExec,
|
|
supportsNoFollow,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Test-only seam for forcing the open-flag/`O_NOFOLLOW` resolution. Used to
|
|
* exercise the Windows-style fallback (no `O_NOFOLLOW`, ancestor walk
|
|
* includes the leaf) on platforms where `fs.constants.O_NOFOLLOW` is a
|
|
* non-configurable native data property and cannot be patched directly.
|
|
*
|
|
* @internal
|
|
*/
|
|
export function setReadOpenFlagsResolverForTest(
|
|
resolver: (() => ReadOpenFlagsResolution) | undefined,
|
|
): void {
|
|
readOpenFlagsResolverForTest = resolver;
|
|
}
|
|
|
|
// Resolves the absolute path associated with an open fd via the kernel-backed
|
|
// `/proc/self/fd/<fd>` (Linux) or `/dev/fd/<fd>` (some BSDs). Returns null
|
|
// when no fd-path endpoint is available. Note: on macOS `/dev/fd/N` is a
|
|
// character device rather than a symlink, so `readlink` fails with EINVAL
|
|
// there and the caller must use the ancestor-walk fallback instead.
|
|
async function resolveOpenedReadablePath(fd: number): Promise<string | null> {
|
|
for (const fdPath of [`/proc/self/fd/${fd}`, `/dev/fd/${fd}`]) {
|
|
try {
|
|
const openedPath = await fsPromises.readlink(fdPath);
|
|
return normalizeOpenedReadablePath(openedPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeOpenedReadablePath(openedPath: string): string {
|
|
const deletedSuffix = " (deleted)";
|
|
const withoutDeletedSuffix = openedPath.endsWith(deletedSuffix)
|
|
? openedPath.slice(0, -deletedSuffix.length)
|
|
: openedPath;
|
|
return path.resolve(withoutDeletedSuffix);
|
|
}
|
|
|
|
// File identity comparison with win32-aware `dev=0` handling, matching the
|
|
// shared `src/infra/file-identity.ts` contract. Kept local because extension
|
|
// production code is not allowed to reach into core `src/**` by relative
|
|
// import, and this helper is not yet part of the `openclaw/plugin-sdk/*`
|
|
// public surface. Stats here come from `FileHandle.stat()` / `fs.promises.stat()`
|
|
// with no `{ bigint: true }` option, so all fields are numbers.
|
|
function sameFileIdentity(
|
|
left: { dev: number; ino: number },
|
|
right: { dev: number; ino: number },
|
|
platform: NodeJS.Platform = process.platform,
|
|
): boolean {
|
|
if (left.ino !== right.ino) {
|
|
return false;
|
|
}
|
|
if (left.dev === right.dev) {
|
|
return true;
|
|
}
|
|
// On Windows, path-based stat can report `dev=0` while fd-based stat reports
|
|
// a real volume serial. Treat either side `dev=0` as "unknown device"
|
|
// rather than a mismatch so legitimate Windows fallback reads are not
|
|
// rejected.
|
|
return platform === "win32" && (left.dev === 0 || right.dev === 0);
|
|
}
|