Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/test/file/path-traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

/*
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/test/tool/isolated-git-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
10 changes: 7 additions & 3 deletions packages/shared/src/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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))
}
}
8 changes: 8 additions & 0 deletions packages/shared/test/filesystem/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
19 changes: 19 additions & 0 deletions packages/shared/test/filesystem/windows-path.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading