From 45d58717a4ae68f8fce6f08cb165f62fd6c63cbf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 5 Aug 2026 15:15:02 -0400 Subject: [PATCH] fix(core): bound protected home searches (#40518) --- .../core/src/filesystem/location-watcher.ts | 3 +- packages/core/src/filesystem/protected.ts | 4 ++ packages/core/src/filesystem/search.ts | 24 ++++---- packages/core/src/ripgrep.ts | 2 + packages/core/test/filesystem/search.test.ts | 58 ++++++++++++++++++- packages/core/test/ripgrep.test.ts | 23 ++++++++ 6 files changed, 100 insertions(+), 14 deletions(-) diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index a8b1544c775..108517a5661 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Context, Effect, Layer, Stream } from "effect" import { FileSystem } from "@opencode-ai/schema/filesystem" -import os from "os" import path from "path" import { Config } from "../config" import { Bus } from "../bus" @@ -44,7 +43,7 @@ const layer = Layer.effect( const config = (yield* configService.entries()) .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) - const home = path.resolve(location.directory) === path.resolve(os.homedir()) + const home = Protected.isHome(location.directory) if (!home && location.vcs) { const updates = yield* watcher.subscribe({ diff --git a/packages/core/src/filesystem/protected.ts b/packages/core/src/filesystem/protected.ts index a7646dfb48f..c771ae20ded 100644 --- a/packages/core/src/filesystem/protected.ts +++ b/packages/core/src/filesystem/protected.ts @@ -3,6 +3,10 @@ import path from "path" const home = os.homedir() +export function isHome(directory: string) { + return path.resolve(directory) === path.resolve(home) +} + const DARWIN_HOME = [ "Music", "Pictures", diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 281d127ce99..9aa77413ddf 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -10,6 +10,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" +import { Protected } from "./protected" export interface Interface { readonly find: (input: FileSystem.FindInput) => Effect.Effect @@ -33,11 +34,13 @@ export const ripgrepLayer = Layer.effect( const scope = yield* Scope.Scope const files: string[] = [] const directories = new Set() + const home = Protected.isHome(location.directory) yield* ripgrep .find({ cwd: location.directory, pattern: "*", - limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000, + limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000, + exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined, onEntry: (entry) => Effect.sync(() => { files.push(entry.path) @@ -232,15 +235,16 @@ export const fffLayer = Layer.effect( }), ) -export const layer = (options?: Options) => Layer.unwrap( - Effect.gen(function* () { - if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available()) - return ripgrepLayer - const location = yield* Location.Service - // Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree. - return location.vcs ? fffLayer : ripgrepLayer - }), -) +export const layer = (options?: Options) => + Layer.unwrap( + Effect.gen(function* () { + if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available()) + return ripgrepLayer + const location = yield* Location.Service + // Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree. + return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer + }), + ) export function configured(options?: Options) { return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] }) diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 4837a7fb2fd..fa639731727 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -52,6 +52,7 @@ export interface FindInput { readonly cwd: string readonly pattern: string readonly limit: number + readonly exclude?: readonly string[] readonly hidden?: boolean readonly follow?: boolean readonly signal?: AbortSignal @@ -195,6 +196,7 @@ const layer = Layer.effect( ...(input.hidden ? ["--hidden"] : []), ...(input.follow ? ["--follow"] : []), ...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]), + ...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`), "--glob=!**/.git/**", ".", ], diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index ab75d7165c0..488c17bab8d 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -1,10 +1,17 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import fs from "fs/promises" +import os from "os" import path from "path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Protected } from "@opencode-ai/core/filesystem/protected" +import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Location } from "@opencode-ai/core/location" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -42,3 +49,50 @@ describe("Ripgrep", () => { ), ) }) + +describe("FileSystemSearch", () => { + test("bounds a home scan even when home is detected as a repository", async () => { + let observed: Ripgrep.FindInput | undefined + const home = AbsolutePath.make(os.homedir()) + const layer = AppNodeBuilder.build(FileSystemSearch.node, [ + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }), + ), + ), + ], + [ + Ripgrep.node, + Layer.succeed( + Ripgrep.Service, + Ripgrep.Service.of({ + find: (input) => + Effect.gen(function* () { + observed = input + if (input.onEntry) + yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })) + return [] + }), + glob: () => Effect.succeed([]), + grep: () => Effect.succeed([]), + }), + ), + ], + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const search = yield* FileSystemSearch.Service + yield* Effect.sleep("10 millis") + expect(observed?.limit).toBe(100_000) + expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`)) + expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe( + RelativePath.make(`src${path.sep}`), + ) + }).pipe(Effect.provide(layer), Effect.scoped), + ) + }) +}) diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 8b8ca3ff0ad..49a932b732c 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -63,6 +63,29 @@ describe("Ripgrep", () => { ), ) + it.live("excludes protected directory trees from catch-all find results", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures"))) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n")) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n")) + + const files = yield* (yield* Ripgrep.Service).find({ + cwd: tmp.path, + pattern: "*", + limit: 10, + exclude: ["Pictures/**"], + }) + + expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt")) + expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg")) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("returns a bounded preview for matches on oversized lines", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),