From d1d3292abd03abb60b46fd130c1d46b418b5c430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 11 Aug 2026 13:52:46 +0800 Subject: [PATCH] fix(filesystem): reject Windows cross-drive containment --- packages/opencode/src/util/filesystem.ts | 10 +++++++--- .../opencode/test/file/path-traversal.test.ts | 4 ++++ .../test/tool/isolated-git-guard.test.ts | 4 ++++ packages/shared/src/filesystem.ts | 10 +++++++--- .../shared/test/filesystem/filesystem.test.ts | 8 ++++++++ .../test/filesystem/windows-path.test.ts | 19 +++++++++++++++++++ 6 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 packages/shared/test/filesystem/windows-path.test.ts diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 22fdcd55f..e002f6bf0 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve, win32 } from "path" +import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "@mimo-ai/shared/util/glob" @@ -155,14 +155,18 @@ export function windowsPath(p: string): string { .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) ) } +function isWithinRelativePath(rel: string) { + return !isAbsolute(rel) && !/^[A-Za-z]:[\\/]/.test(rel) && !/^\\\\/.test(rel) && !rel.startsWith("..") +} + export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + return !relA || isWithinRelativePath(relA) || !relB || isWithinRelativePath(relB) } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + return isWithinRelativePath(relative(parent, child)) } export async function findUp( diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 53abf0711..5b798ad9f 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -35,6 +35,10 @@ describe("Filesystem.contains", () => { expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) expect(Filesystem.contains("/project", "/projectfile")).toBe(false) }) + + test("rejects Windows paths on different drives", () => { + expect(Filesystem.contains("C:/Users/me/AppData/Local/mimocode/worktree", "D:/workspace/project")).toBe(false) + }) }) /* diff --git a/packages/opencode/test/tool/isolated-git-guard.test.ts b/packages/opencode/test/tool/isolated-git-guard.test.ts index 4200759e0..274ef4d07 100644 --- a/packages/opencode/test/tool/isolated-git-guard.test.ts +++ b/packages/opencode/test/tool/isolated-git-guard.test.ts @@ -175,6 +175,10 @@ describe("isolated-git-guard / isolation signal", () => { expect(isIsolatedWorktree("/Users/me/projects/app", root)).toBe(false) }) + test("a Windows project on another drive is NOT an isolated child", () => { + expect(isIsolatedWorktree("D:/workspace/project", "C:/Users/me/AppData/Local/mimocode/worktree")).toBe(false) + }) + test("undefined directory is not an isolated child", () => { expect(isIsolatedWorktree(undefined, root)).toBe(false) }) diff --git a/packages/shared/src/filesystem.ts b/packages/shared/src/filesystem.ts index 44346be8f..0dc461cd4 100644 --- a/packages/shared/src/filesystem.ts +++ b/packages/shared/src/filesystem.ts @@ -1,5 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join, relative, resolve as pathResolve } from "path" +import { dirname, isAbsolute, join, relative, resolve as pathResolve } from "path" import { realpathSync } from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" @@ -224,13 +224,17 @@ export namespace AppFileSystem { .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) } + function isWithinRelativePath(rel: string) { + return !isAbsolute(rel) && !/^[A-Za-z]:[\\/]/.test(rel) && !/^\\\\/.test(rel) && !rel.startsWith("..") + } + export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + return !relA || isWithinRelativePath(relA) || !relB || isWithinRelativePath(relB) } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + return isWithinRelativePath(relative(parent, child)) } } diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts index c5fca58c5..65e4224ff 100644 --- a/packages/shared/test/filesystem/filesystem.test.ts +++ b/packages/shared/test/filesystem/filesystem.test.ts @@ -329,10 +329,18 @@ describe("AppFileSystem", () => { expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) }) + test("contains rejects Windows paths on different drives", () => { + expect(AppFileSystem.contains("C:/Users/me/AppData/Local/mimocode/worktree", "D:/workspace/project")).toBe(false) + }) + test("overlaps detects overlapping paths", () => { expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) }) + + test("overlaps rejects Windows paths on different drives", () => { + expect(AppFileSystem.overlaps("C:/Users/me/AppData/Local/mimocode/worktree", "D:/workspace/project")).toBe(false) + }) }) }) diff --git a/packages/shared/test/filesystem/windows-path.test.ts b/packages/shared/test/filesystem/windows-path.test.ts new file mode 100644 index 000000000..59734f88e --- /dev/null +++ b/packages/shared/test/filesystem/windows-path.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, mock, test } from "bun:test" +import path from "path" + +void mock.module("path", () => ({ + ...path, + relative: path.win32.relative, +})) + +const { AppFileSystem } = await import("@mimo-ai/shared/filesystem") + +describe("AppFileSystem Windows path helpers", () => { + test("contains rejects paths on a different drive", () => { + expect(AppFileSystem.contains("C:/Users/me/AppData/Local/mimocode/worktree", "D:/workspace/project")).toBe(false) + }) + + test("overlaps rejects paths on a different drive", () => { + expect(AppFileSystem.overlaps("C:/Users/me/AppData/Local/mimocode/worktree", "D:/workspace/project")).toBe(false) + }) +})