Compare commits

...

1 Commits

Author SHA1 Message Date
Brendan Allan 2106509157 fix(server): return 404 for missing files 2026-08-22 15:34:00 +08:00
3 changed files with 66 additions and 11 deletions
+33 -6
View File
@@ -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),
@@ -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[]>
}
@@ -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(
+8 -5
View File
@@ -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 })
}),
)
+25
View File
@@ -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"
@@ -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)