fix(core): expose filesystem failures

This commit is contained in:
Kit Langton 2026-06-21 13:14:37 -04:00
parent 82d9cab48d
commit e8d632834c
5 changed files with 76 additions and 21 deletions

View file

@ -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<Entry[]>
readonly read: (
input: ReadInput,
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
@ -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) => {

View file

@ -21,3 +21,8 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
text: Schema.String,
submatches: Schema.Array(Submatch),
}) {}
export class PathError extends Schema.TaggedErrorClass<PathError>()("FileSystem.PathError", {
path: Schema.String,
reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]),
}) {}

View file

@ -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 = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
describe("FileSystem", () => {
const expectFail = (exit: Exit.Exit<unknown, unknown>) => {
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)),
),
)

View file

@ -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<FileSystemEntry[]>
read(input: {
readonly path: string
}): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, FileSystemError>
list(input?: { readonly path?: string }): Effect.Effect<FileSystemEntry[], FileSystemError>
find(input: {
readonly query: string
readonly type?: "file" | "directory"

View file

@ -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"