diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe8840..a7f90ced06 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util" import { Location } from "./location" import { PositiveInt, RelativePath } from "./schema" import { FileSystemSearch } from "./filesystem/search" -import { Entry, Match } from "./filesystem/schema" -export { Entry, Match, Submatch } from "./filesystem/schema" +import { Entry, Match, PathError } from "./filesystem/schema" +export { Entry, Match, PathError, Submatch } from "./filesystem/schema" export const ReadInput = Schema.Struct({ path: RelativePath, @@ -58,8 +58,10 @@ export const Event = { } export interface Interface { - readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> - readonly list: (input?: ListInput) => Effect.Effect + readonly read: ( + input: ReadInput, + ) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error> + readonly list: (input?: ListInput) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect readonly grep: (input: GrepInput) => Effect.Effect @@ -77,9 +79,9 @@ const baseLayer = Layer.effect( const resolve = Effect.fnUntraced(function* (input?: RelativePath) { const absolute = path.resolve(location.directory, input ?? ".") if (!FSUtil.contains(location.directory, absolute)) - return yield* Effect.die(new Error("Path escapes the location")) - const real = yield* fs.realPath(absolute).pipe(Effect.orDie) - if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) + return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" }) + const real = yield* fs.realPath(absolute) + if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" }) return { absolute, real, directory: location.directory, root } }) return Service.of({ @@ -88,19 +90,18 @@ const baseLayer = Layer.effect( grep: search.grep, read: Effect.fn("FileSystem.read")(function* (input) { const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const info = yield* fs.stat(target.real) + if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" }) return { - content: yield* fs.readFile(target.real).pipe(Effect.orDie), + content: yield* fs.readFile(target.real), mime: FSUtil.mimeType(target.real), } }), list: Effect.fn("FileSystem.list")(function* (input = {}) { const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) + const info = yield* fs.stat(target.real) + if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" }) return yield* fs.readDirectoryEntries(target.real).pipe( - Effect.orDie, Effect.map((items) => items .flatMap((item) => { diff --git a/packages/core/src/filesystem/schema.ts b/packages/core/src/filesystem/schema.ts index 6a2cb48413..1eeffa7d37 100644 --- a/packages/core/src/filesystem/schema.ts +++ b/packages/core/src/filesystem/schema.ts @@ -21,3 +21,8 @@ export class Match extends Schema.Class("FileSystem.Match")({ text: Schema.String, submatches: Schema.Array(Submatch), }) {} + +export class PathError extends Schema.TaggedErrorClass()("FileSystem.PathError", { + path: Schema.String, + reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]), +}) {} diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index a3ac24a905..971210404a 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -31,6 +31,13 @@ const withTmp = (f: (directory: string) => Effect.Effect) => ).pipe(Effect.flatMap((tmp) => f(tmp.path))) describe("FileSystem", () => { + const expectFail = (exit: Exit.Exit) => { + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(Cause.hasFails(exit.cause)).toBe(true) + expect(Cause.hasDies(exit.cause)).toBe(false) + } + it.live("reads text and binary files", () => withTmp((directory) => Effect.gen(function* () { @@ -60,13 +67,39 @@ describe("FileSystem", () => { ), ) - it.live("rejects lexical escapes", () => + it.live("fails for missing paths", () => withTmp((directory) => Effect.gen(function* () { - const result = yield* (yield* FileSystem.Service) - .read({ path: RelativePath.make("../outside.txt") }) + const exit = yield* (yield* FileSystem.Service) + .read({ path: RelativePath.make("missing.txt") }) .pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) + expectFail(exit) + }).pipe(provide(directory)), + ), + ) + + it.live("fails for wrong path kinds", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test")) + const service = yield* FileSystem.Service + expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit)) + expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit)) + }).pipe(provide(directory)), + ), + ) + + it.live("fails for lexical and symlink escapes", () => + withTmp((directory) => + Effect.gen(function* () { + const outside = path.join(directory, "..", "outside.txt") + yield* Effect.promise(() => fs.writeFile(outside, "outside")) + yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt"))) + const service = yield* FileSystem.Service + expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)) + expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit)) + yield* Effect.promise(() => fs.rm(outside)) }).pipe(provide(directory)), ), ) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 0e6ef98c24..750d25942b 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,10 +1,11 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" -import { effectCmd } from "../../effect-cmd" +import { CliError, effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" const filesystem = (effect: Effect.Effect) => @@ -13,6 +14,8 @@ const filesystem = (effect: Effect.Effect) => Effect.provide(LocationServiceMap.layer), ) +const fileError = (error: FileSystem.PathError | FSUtil.Error) => new CliError({ message: error.message }) + const FileSearchCommand = effectCmd({ command: "search ", describe: "search files by query", @@ -38,7 +41,9 @@ const FileReadCommand = effectCmd({ description: "File path to read", }), handler: Effect.fn("Cli.debug.file.read")(function* (args) { - const file = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) }))) + const file = yield* filesystem( + FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })), + ).pipe(Effect.mapError(fileError)) process.stdout.write( JSON.stringify( { content: Buffer.from(file.content).toString("base64"), encoding: "base64", mime: file.mime }, @@ -59,7 +64,9 @@ const FileListCommand = effectCmd({ description: "File path to list", }), handler: Effect.fn("Cli.debug.file.list")(function* (args) { - const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) }))) + const files = yield* filesystem( + FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })), + ).pipe(Effect.mapError(fileError)) process.stdout.write(JSON.stringify(files, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457..cfc804d0f6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -10,6 +10,10 @@ import ignore from "ignore" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { InvalidRequestError } from "../errors" + +const invalidRequest = (error: FileSystem.PathError | FSUtil.Error) => + new InvalidRequestError({ message: error.message, kind: error._tag }) export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { @@ -79,7 +83,9 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .readFileString(path.join(location.project.directory, ".ignore")) .pipe(Effect.catch(() => Effect.succeed(""))) if (ignorefile) ignored.add(ignorefile) - return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({ + return (yield* fs + .list({ path: RelativePath.make(ctx.query.path) }) + .pipe(Effect.mapError(invalidRequest))).map((item) => ({ name: path.basename(item.path), path: item.path, absolute: path.resolve(location.directory, item.path), @@ -101,6 +107,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl return yield* filesystem( FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })), ).pipe( + Effect.mapError(invalidRequest), Effect.flatMap((item) => Effect.gen(function* () { const text = item.content.includes(0) diff --git a/packages/plugin/src/v2/effect/filesystem.ts b/packages/plugin/src/v2/effect/filesystem.ts index d242b2a692..e530a51d64 100644 --- a/packages/plugin/src/v2/effect/filesystem.ts +++ b/packages/plugin/src/v2/effect/filesystem.ts @@ -1,9 +1,25 @@ import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types" import type { Effect } from "effect" +import type { PlatformError } from "effect/PlatformError" + +export type FileSystemError = + | PlatformError + | { + readonly _tag: "FileSystemError" + readonly method: string + readonly cause?: unknown + } + | { + readonly _tag: "FileSystem.PathError" + readonly path: string + readonly reason: "lexical_escape" | "symlink_escape" | "not_file" | "not_directory" + } export interface FileSystem { - read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> - list(input?: { readonly path?: string }): Effect.Effect + read(input: { + readonly path: string + }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, FileSystemError> + list(input?: { readonly path?: string }): Effect.Effect find(input: { readonly query: string readonly type?: "file" | "directory" diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 46c4574515..6a83e01a46 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -7,7 +7,7 @@ export type { AISDK, AISDKHooks } from "./aisdk.js" export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js" export type { Command, CommandDraft } from "./command.js" export type { Event, EventMap } from "./event.js" -export type { FileSystem } from "./filesystem.js" +export type { FileSystem, FileSystemError } from "./filesystem.js" export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js" export type { Location } from "./location.js" export type { Npm } from "./npm.js" diff --git a/packages/server/src/handlers/fs.ts b/packages/server/src/handlers/fs.ts index 963bf51d85..e2cb4fd832 100644 --- a/packages/server/src/handlers/fs.ts +++ b/packages/server/src/handlers/fs.ts @@ -1,21 +1,28 @@ import { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" import { RelativePath } from "@opencode-ai/core/schema" import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { InvalidRequestError } from "../errors" import { response } from "../groups/location" +const invalidRequest = (error: FileSystem.PathError | FSUtil.Error) => + new InvalidRequestError({ message: error.message, kind: error._tag }) + export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) => Effect.gen(function* () { return handlers .handleRaw("fs.read", (ctx) => Effect.gen(function* () { - const file = yield* (yield* FileSystem.Service).read({ - path: RelativePath.make( - decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), - ), - }) + const file = yield* (yield* FileSystem.Service) + .read({ + path: RelativePath.make( + decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)), + ), + }) + .pipe(Effect.mapError(invalidRequest)) return HttpServerResponse.uint8Array(file.content, { contentType: file.mime }) }), ) @@ -23,7 +30,7 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler response( Effect.gen(function* () { const fs = yield* FileSystem.Service - return yield* fs.list(ctx.query) + return yield* fs.list(ctx.query).pipe(Effect.mapError(invalidRequest)) }), ), )