Skip to content
Open
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
39 changes: 33 additions & 6 deletions packages/core/src/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const ReadInput = Schema.Struct({
})
export type ReadInput = typeof ReadInput.Type

export class NotFoundError extends Schema.TaggedError<NotFoundError>()("FileSystem.NotFoundError", {
path: RelativePath,
}) {}

export const Content = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(Schema.optional),
Expand Down Expand Up @@ -50,7 +54,9 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
export const Event = FileSystem.Event

export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly read: (
input: ReadInput,
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
}
Expand All @@ -68,23 +74,44 @@ const baseLayer = Layer.effect(
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)
const real = yield* fs.realPath(absolute)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory }
})
return Service.of({
find: search.find,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const target = yield* resolve(input.path).pipe(
Effect.catchReason(
"PlatformError",
"NotFound",
() => Effect.fail(new NotFoundError({ path: input.path })),
(_, error) => Effect.die(error),
),
)
const info = yield* fs.stat(target.real).pipe(
Effect.catchReason(
"PlatformError",
"NotFound",
() => Effect.fail(new NotFoundError({ path: input.path })),
(_, error) => Effect.die(error),
),
)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return {
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
content: yield* fs.readFile(target.real).pipe(
Effect.catchReason(
"PlatformError",
"NotFound",
() => Effect.fail(new NotFoundError({ path: input.path })),
(_, error) => Effect.die(error),
),
),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const target = yield* resolve(input.path).pipe(Effect.orDie)
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"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Expand Down
13 changes: 8 additions & 5 deletions packages/server/src/handlers/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
.handleRaw("fs.read", (ctx) =>
Effect.gen(function* () {
const fs = yield* FileSystem.Service
const file = yield* fs.read({
path: RelativePath.make(
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
),
})
const file = yield* fs
.read({
path: RelativePath.make(
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
),
})
.pipe(Effect.catchTag("FileSystem.NotFoundError", () => Effect.succeed(undefined)))
if (!file) return HttpServerResponse.empty({ status: 404 })
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
}),
)
Expand Down
25 changes: 25 additions & 0 deletions packages/server/test/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { expect } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"

Expand Down Expand Up @@ -52,6 +55,28 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)

it.live("returns 404 when a previously readable file is deleted", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-fs-read-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const file = path.join(tmp.path, "deleted.txt")
yield* Effect.promise(() => fs.writeFile(file, "content"))
const url = new URL("http://opencode.local/api/fs/read/deleted.txt")
url.searchParams.set("location[directory]", tmp.path)

const readable = yield* Effect.promise(() => handler(new Request(url)))
expect(readable.status).toBe(200)

yield* Effect.promise(() => fs.unlink(file))
const missing = yield* Effect.promise(() => handler(new Request(url)))
expect(missing.status).toBe(404)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.scoped),
)

it.live("serves the session view operation and missing-session error", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
Expand Down
Loading