mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 06:56:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3913203f0f |
No files matched your search
@@ -2,7 +2,7 @@ export * as FileSystem from "./filesystem.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer, type Option, type PlatformError, Schema, type Stream } from "effect"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Location } from "./location.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
@@ -87,10 +87,19 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
|
||||
|
||||
export const Event = FileSystem.Event
|
||||
|
||||
export interface File {
|
||||
readonly path: AbsolutePath
|
||||
readonly mime: string
|
||||
readonly size: number
|
||||
readonly mtime: Option.Option<Date>
|
||||
readonly stream: (options?: {
|
||||
readonly offset?: number
|
||||
readonly bytesToRead?: number
|
||||
}) => Stream.Stream<Uint8Array, PlatformError.PlatformError>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (
|
||||
input: ReadInput,
|
||||
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError>
|
||||
readonly read: (input: ReadInput) => Effect.Effect<File, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
/** Writes a file at an absolute path or one relative to the location; not confined to it. */
|
||||
@@ -129,25 +138,25 @@ const baseLayer = Layer.effect(
|
||||
return Effect.die(cause)
|
||||
}),
|
||||
)
|
||||
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)
|
||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const resolve = Effect.fnUntraced(function* (input: RelativePath) {
|
||||
if (input.includes("\u0000")) return yield* Effect.fail(new NotFoundError({ path: input }))
|
||||
const absolute = path.resolve(location.directory, input)
|
||||
if (!FSUtil.contains(location.directory, absolute)) return yield* Effect.fail(new NotFoundError({ path: input }))
|
||||
const real = yield* fs.realPath(absolute).pipe(
|
||||
Effect.catchReason(
|
||||
"PlatformError",
|
||||
"NotFound",
|
||||
() => Effect.fail(new NotFoundError({ path: input })),
|
||||
(_, error) => Effect.die(error),
|
||||
),
|
||||
)
|
||||
if (!FSUtil.contains(root, real)) return yield* Effect.fail(new NotFoundError({ path: input }))
|
||||
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).pipe(
|
||||
Effect.catchReason(
|
||||
"PlatformError",
|
||||
"NotFound",
|
||||
() => Effect.fail(new NotFoundError({ path: input.path })),
|
||||
(_, error) => Effect.die(error),
|
||||
),
|
||||
)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(
|
||||
Effect.catchReason(
|
||||
"PlatformError",
|
||||
@@ -156,17 +165,13 @@ const baseLayer = Layer.effect(
|
||||
(_, error) => Effect.die(error),
|
||||
),
|
||||
)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
if (info.type !== "File") return yield* Effect.fail(new NotFoundError({ path: input.path }))
|
||||
return {
|
||||
content: yield* fs.readFile(target.real).pipe(
|
||||
Effect.catchReason(
|
||||
"PlatformError",
|
||||
"NotFound",
|
||||
() => Effect.fail(new NotFoundError({ path: input.path })),
|
||||
(_, error) => Effect.die(error),
|
||||
),
|
||||
),
|
||||
path: AbsolutePath.make(target.real),
|
||||
mime: FSUtil.mimeType(target.real),
|
||||
size: Number(info.size),
|
||||
mtime: info.mtime,
|
||||
stream: (options) => fs.stream(target.real, options),
|
||||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, PlatformError } from "effect"
|
||||
import { Cause, Effect, Exit, Layer, Option, PlatformError, Stream } from "effect"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode/core/filesystem"
|
||||
@@ -37,13 +37,18 @@ describe("FileSystem", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "text.txt"), "hello"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2, 3, 4])))
|
||||
const service = yield* FileSystem.Service
|
||||
const text = yield* service.read({ path: RelativePath.make("text.txt") })
|
||||
const binary = yield* service.read({ path: RelativePath.make("data.bin") })
|
||||
expect(new TextDecoder().decode(text.content)).toBe("hello")
|
||||
expect(new TextDecoder().decode(yield* Stream.mkUint8Array(text.stream()))).toBe("hello")
|
||||
expect(text.mime).toBe("text/plain")
|
||||
expect(binary.content).toEqual(new Uint8Array([0, 1, 2]))
|
||||
expect(text.size).toBe(5)
|
||||
expect(Option.isSome(text.mtime)).toBe(true)
|
||||
expect(yield* Stream.mkUint8Array(binary.stream())).toEqual(new Uint8Array([0, 1, 2, 3, 4]))
|
||||
expect(yield* Stream.mkUint8Array(binary.stream({ offset: 1, bytesToRead: 3 }))).toEqual(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
@@ -190,9 +195,10 @@ describe("FileSystem", () => {
|
||||
// the location root to the real directory.
|
||||
const read = yield* FileSystem.Service.pipe(
|
||||
Effect.flatMap((service) => service.read({ path: RelativePath.make("file.txt") })),
|
||||
Effect.flatMap((file) => Stream.mkUint8Array(file.stream())),
|
||||
provide(link),
|
||||
)
|
||||
expect(new TextDecoder().decode(read.content)).toBe("linked")
|
||||
expect(new TextDecoder().decode(read)).toBe("linked")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8481,7 +8481,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Serve one file relative to the requested location.",
|
||||
"description": "Stream one file relative to the requested location with ETag, Last-Modified, and single-range HTTP Range support (206 Partial Content, 416 Range Not Satisfiable, and 304 Not Modified; multi-range and malformed Range headers fall back to 200).",
|
||||
"summary": "Read file"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -39,7 +39,8 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
||||
OpenApi.annotations({
|
||||
identifier: "fs.read",
|
||||
summary: "Read file",
|
||||
description: "Serve one file relative to the requested location.",
|
||||
description:
|
||||
"Stream one file relative to the requested location with ETag, Last-Modified, and single-range HTTP Range support (206 Partial Content, 416 Range Not Satisfiable, and 304 Not Modified; multi-range and malformed Range headers fall back to 200).",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,29 +2,29 @@ import { FileSystem } from "@opencode/core/filesystem"
|
||||
import { RelativePath } from "@opencode/core/schema"
|
||||
import { FileNotFoundError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpEffect, HttpPlatform, type HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const platform = yield* HttpPlatform.HttpPlatform
|
||||
return handlers
|
||||
.handleRaw("fs.read", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const path = yield* decodeRequestPath(ctx.request.url)
|
||||
const fs = yield* FileSystem.Service
|
||||
const file = yield* fs
|
||||
.read({
|
||||
path: RelativePath.make(
|
||||
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
|
||||
),
|
||||
})
|
||||
.read({ path })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new FileNotFoundError({ path: error.path, message: `File not found: ${error.path}` }),
|
||||
),
|
||||
)
|
||||
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
|
||||
return yield* serveFile(ctx.request, path, file).pipe(
|
||||
Effect.provideService(HttpPlatform.HttpPlatform, platform),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle("fs.list", (ctx) =>
|
||||
@@ -53,3 +53,198 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function decodeRequestPath(url: string) {
|
||||
const raw = new URL(url, "http://localhost").pathname.slice(13)
|
||||
return Effect.try({
|
||||
try: () => RelativePath.make(decodeURIComponent(raw)),
|
||||
catch: () => new FileNotFoundError({ path: raw, message: `File not found: ${raw}` }),
|
||||
})
|
||||
}
|
||||
|
||||
const serveFile = Effect.fnUntraced(function* (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
path: RelativePath,
|
||||
file: FileSystem.File,
|
||||
) {
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, res) =>
|
||||
Effect.succeed(HttpServerResponse.removeHeader(res, "content-encoding")),
|
||||
)
|
||||
const fileResponse = (options?: Parameters<typeof HttpServerResponse.file>[1]) =>
|
||||
HttpServerResponse.file(file.path, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": file.mime,
|
||||
"accept-ranges": "bytes",
|
||||
"content-encoding": "identity",
|
||||
...options?.headers,
|
||||
},
|
||||
}).pipe(
|
||||
Effect.catchReason(
|
||||
"PlatformError",
|
||||
"NotFound",
|
||||
() => Effect.fail(new FileNotFoundError({ path, message: `File not found: ${path}` })),
|
||||
(_, error) => Effect.die(error),
|
||||
),
|
||||
)
|
||||
|
||||
const rangeHeader = request.method === "GET" ? request.headers["range"] : undefined
|
||||
const ifRange = request.headers["if-range"]
|
||||
const shouldEvaluateConditionals =
|
||||
request.headers["if-none-match"] !== undefined || request.headers["if-modified-since"] !== undefined
|
||||
|
||||
const fullResponse =
|
||||
shouldEvaluateConditionals || (rangeHeader !== undefined && ifRange !== undefined) || rangeHeader === undefined
|
||||
? yield* fileResponse()
|
||||
: undefined
|
||||
|
||||
if (shouldEvaluateConditionals && fullResponse !== undefined) {
|
||||
const conditional = evaluateConditionalRequest(request, fullResponse)
|
||||
if (conditional !== undefined) return conditional
|
||||
}
|
||||
|
||||
if (rangeHeader === undefined) {
|
||||
return fullResponse ?? (yield* fileResponse())
|
||||
}
|
||||
|
||||
if (ifRange !== undefined && fullResponse !== undefined && !matchesIfRange(ifRange, fullResponse)) {
|
||||
return fullResponse
|
||||
}
|
||||
|
||||
const parsedRange = parseRange(rangeHeader, file.size)
|
||||
if (parsedRange === undefined) {
|
||||
return fullResponse ?? (yield* fileResponse())
|
||||
}
|
||||
|
||||
if (parsedRange === "unsatisfiable") {
|
||||
return HttpServerResponse.empty({
|
||||
status: 416,
|
||||
headers: {
|
||||
"accept-ranges": "bytes",
|
||||
"content-range": `bytes */${file.size}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return yield* fileResponse({
|
||||
status: 206,
|
||||
offset: parsedRange.start,
|
||||
bytesToRead: parsedRange.end - parsedRange.start + 1,
|
||||
headers: {
|
||||
"content-range": `bytes ${parsedRange.start}-${parsedRange.end}/${file.size}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
function parseInteger(value: string) {
|
||||
if (!/^\d+$/.test(value)) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
header: string,
|
||||
fileSize: number,
|
||||
): { readonly start: number; readonly end: number } | "unsatisfiable" | undefined {
|
||||
const value = header.trim()
|
||||
if (!value.toLowerCase().startsWith("bytes=")) return undefined
|
||||
const rangeValue = value.slice(6).trim()
|
||||
if (rangeValue.length === 0 || rangeValue.includes(",")) return undefined
|
||||
const separatorIndex = rangeValue.indexOf("-")
|
||||
if (separatorIndex === -1) return undefined
|
||||
const startPart = rangeValue.slice(0, separatorIndex).trim()
|
||||
const endPart = rangeValue.slice(separatorIndex + 1).trim()
|
||||
if (startPart === "" && endPart === "") return undefined
|
||||
if (startPart === "") {
|
||||
const suffixLength = parseInteger(endPart)
|
||||
if (suffixLength === undefined) return undefined
|
||||
if (suffixLength === 0 || fileSize === 0) return "unsatisfiable"
|
||||
return {
|
||||
start: Math.max(fileSize - suffixLength, 0),
|
||||
end: fileSize - 1,
|
||||
}
|
||||
}
|
||||
const start = parseInteger(startPart)
|
||||
if (start === undefined) return undefined
|
||||
if (endPart === "") {
|
||||
if (start >= fileSize) return "unsatisfiable"
|
||||
return {
|
||||
start,
|
||||
end: fileSize - 1,
|
||||
}
|
||||
}
|
||||
const end = parseInteger(endPart)
|
||||
if (end === undefined) return undefined
|
||||
if (start > end || start >= fileSize) return "unsatisfiable"
|
||||
return {
|
||||
start,
|
||||
end: Math.min(end, fileSize - 1),
|
||||
}
|
||||
}
|
||||
|
||||
function stripWeakEtagPrefix(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return /^w\//i.test(trimmed) ? trimmed.slice(2) : trimmed
|
||||
}
|
||||
|
||||
function matchesIfNoneMatch(ifNoneMatch: string, etag: string | undefined) {
|
||||
const normalizedEtag = etag === undefined ? undefined : stripWeakEtagPrefix(etag)
|
||||
return ifNoneMatch.split(",").some((candidate) => {
|
||||
const value = candidate.trim()
|
||||
if (value === "") return false
|
||||
if (value === "*") return true
|
||||
return normalizedEtag !== undefined && stripWeakEtagPrefix(value) === normalizedEtag
|
||||
})
|
||||
}
|
||||
|
||||
function isNotModifiedSince(ifModifiedSince: string, lastModified: string | undefined) {
|
||||
if (lastModified === undefined) return false
|
||||
const ifModifiedSinceMs = Date.parse(ifModifiedSince)
|
||||
if (Number.isNaN(ifModifiedSinceMs)) return false
|
||||
const lastModifiedMs = Date.parse(lastModified)
|
||||
if (Number.isNaN(lastModifiedMs)) return false
|
||||
return lastModifiedMs <= ifModifiedSinceMs
|
||||
}
|
||||
|
||||
function matchesIfRange(ifRange: string, response: HttpServerResponse.HttpServerResponse) {
|
||||
const value = ifRange.trim()
|
||||
if (value === "") return false
|
||||
if (value.startsWith('"') || /^w\/"/i.test(value)) {
|
||||
const etag = response.headers["etag"]
|
||||
if (etag === undefined || !value.endsWith('"')) return false
|
||||
return stripWeakEtagPrefix(value) === stripWeakEtagPrefix(etag)
|
||||
}
|
||||
const lastModified = response.headers["last-modified"]
|
||||
if (lastModified === undefined) return false
|
||||
const ifRangeMs = Date.parse(value)
|
||||
if (Number.isNaN(ifRangeMs)) return false
|
||||
const lastModifiedMs = Date.parse(lastModified)
|
||||
if (Number.isNaN(lastModifiedMs)) return false
|
||||
return lastModifiedMs === ifRangeMs
|
||||
}
|
||||
|
||||
function notModifiedResponse(response: HttpServerResponse.HttpServerResponse) {
|
||||
return HttpServerResponse.empty({
|
||||
status: 304,
|
||||
headers: {
|
||||
...(response.headers["etag"] !== undefined ? { etag: response.headers["etag"] } : {}),
|
||||
...(response.headers["cache-control"] !== undefined ? { "cache-control": response.headers["cache-control"] } : {}),
|
||||
...(response.headers["last-modified"] !== undefined ? { "last-modified": response.headers["last-modified"] } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function evaluateConditionalRequest(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
response: HttpServerResponse.HttpServerResponse,
|
||||
) {
|
||||
const ifNoneMatch = request.headers["if-none-match"]
|
||||
if (ifNoneMatch !== undefined) {
|
||||
return matchesIfNoneMatch(ifNoneMatch, response.headers["etag"]) ? notModifiedResponse(response) : undefined
|
||||
}
|
||||
const ifModifiedSince = request.headers["if-modified-since"]
|
||||
if (ifModifiedSince !== undefined && isNotModifiedSince(ifModifiedSince, response.headers["last-modified"])) {
|
||||
return notModifiedResponse(response)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { SessionTransfer } from "@opencode/core/session/transfer"
|
||||
import { ShellSelect } from "@opencode/core/shell/select"
|
||||
import { Job } from "@opencode/core/job"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
@@ -32,9 +33,9 @@ import { SdkPlugins } from "@opencode/core/plugin/sdk"
|
||||
import { WellKnown } from "@opencode/core/wellknown"
|
||||
import { Workspace } from "@opencode/core/workspace"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpPlatform, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Option } from "effect"
|
||||
import { Api } from "./api"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { CorsConfig } from "./cors"
|
||||
@@ -51,6 +52,7 @@ import type { ServerOptions } from "./options"
|
||||
|
||||
const applicationServiceNodes = [
|
||||
Global.node,
|
||||
FSUtil.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
EventLogger.node,
|
||||
@@ -160,6 +162,9 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
return serviceLayer.pipe(
|
||||
Layer.flatMap((context) => {
|
||||
const services = Layer.succeedContext(context)
|
||||
const platform = Layer.fresh(HttpPlatform.layer).pipe(
|
||||
Layer.provide(Layer.succeed(FileSystem.FileSystem, Context.get(context, FSUtil.Service))),
|
||||
)
|
||||
const requestServices = Layer.merge(
|
||||
Layer.succeedContext(
|
||||
Context.pick(
|
||||
@@ -174,7 +179,13 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
ServerInfo.layer(serviceURLs, Context.get(context, Global.Service).tmp, options.app),
|
||||
)
|
||||
const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
Layer.provide(handlers.pipe(Layer.provide(services), Layer.provide(Layer.succeed(CorsConfig, options)))),
|
||||
Layer.provide(
|
||||
handlers.pipe(
|
||||
Layer.provide(platform),
|
||||
Layer.provide(services),
|
||||
Layer.provide(Layer.succeed(CorsConfig, options)),
|
||||
),
|
||||
),
|
||||
Layer.provide(formLocationLayer),
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(layer),
|
||||
|
||||
@@ -77,3 +77,214 @@ it.live(
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"streams files with Range, conditional requests, confinement, and multi-megabyte byte integrity",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const current = path.join(tmp.path, "project")
|
||||
const outside = path.join(tmp.path, "outside")
|
||||
const largeBytes = Uint8Array.from(
|
||||
{ length: 4 * 1024 * 1024 + 321 },
|
||||
(_, i) => (i * 31 + (i >> 8)) & 0xff,
|
||||
)
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(current, "nested"), { recursive: true })
|
||||
await fs.mkdir(outside, { recursive: true })
|
||||
await fs.writeFile(path.join(current, "clip.mp4"), "0123456789")
|
||||
await fs.writeFile(path.join(current, "empty.bin"), "")
|
||||
await fs.writeFile(path.join(current, "large.mp4"), largeBytes)
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "secret")
|
||||
await fs.symlink(outside, path.join(current, "escape-link"), "junction")
|
||||
})
|
||||
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const readUrl = (relativePath: string) => {
|
||||
const url = new URL(`/api/fs/read/${relativePath}`, server.base)
|
||||
url.searchParams.set("location[directory]", current)
|
||||
return url
|
||||
}
|
||||
const request = (relativePath: string, init?: RequestInit) =>
|
||||
Effect.promise(() =>
|
||||
fetch(readUrl(relativePath), {
|
||||
...init,
|
||||
headers: {
|
||||
...server.headers,
|
||||
...init?.headers,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// 1. Full 200 read headers and body
|
||||
const full = yield* request("clip.mp4")
|
||||
expect(full.status).toBe(200)
|
||||
expect(full.headers.get("accept-ranges")).toBe("bytes")
|
||||
expect(full.headers.get("content-length")).toBe("10")
|
||||
expect(full.headers.get("content-type")).toBe("video/mp4")
|
||||
expect(full.headers.get("content-encoding")).toBeNull()
|
||||
const etag = full.headers.get("etag")
|
||||
const lastModified = full.headers.get("last-modified")
|
||||
expect(etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/)
|
||||
expect(lastModified).toBeTruthy()
|
||||
expect(yield* Effect.promise(() => full.text())).toBe("0123456789")
|
||||
|
||||
// 2. HEAD probe (and HEAD with Range ignores Range)
|
||||
const head = yield* request("clip.mp4", { method: "HEAD", headers: { Range: "bytes=0-3" } })
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get("accept-ranges")).toBe("bytes")
|
||||
expect(head.headers.get("content-length")).toBe("10")
|
||||
expect(head.headers.get("content-type")).toBe("video/mp4")
|
||||
expect(head.headers.get("content-range")).toBeNull()
|
||||
expect(head.headers.get("etag")).toBe(etag)
|
||||
expect(head.headers.get("last-modified")).toBe(lastModified)
|
||||
expect(yield* Effect.promise(() => head.text())).toBe("")
|
||||
|
||||
// 3. Explicit, open-ended, clamped, and suffix Range forms -> 206 Partial Content
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["bytes=0-0", "0", "bytes 0-0/10"],
|
||||
["bytes=2-5", "2345", "bytes 2-5/10"],
|
||||
["bytes=7-99", "789", "bytes 7-9/10"],
|
||||
["bytes=6-", "6789", "bytes 6-9/10"],
|
||||
["bytes=-3", "789", "bytes 7-9/10"],
|
||||
["bytes=-99", "0123456789", "bytes 0-9/10"],
|
||||
] as const,
|
||||
([range, expectedBody, expectedContentRange]) =>
|
||||
Effect.gen(function* () {
|
||||
const partial = yield* request("clip.mp4", { headers: { Range: range } })
|
||||
expect({ range, status: partial.status }).toEqual({ range, status: 206 })
|
||||
expect(partial.headers.get("accept-ranges")).toBe("bytes")
|
||||
expect(partial.headers.get("content-range")).toBe(expectedContentRange)
|
||||
expect(partial.headers.get("content-length")).toBe(String(expectedBody.length))
|
||||
expect(partial.headers.get("content-type")).toBe("video/mp4")
|
||||
expect(partial.headers.get("etag")).toBe(etag)
|
||||
expect(partial.headers.get("last-modified")).toBe(lastModified)
|
||||
expect(yield* Effect.promise(() => partial.text())).toBe(expectedBody)
|
||||
}),
|
||||
)
|
||||
|
||||
// 4. Unsatisfiable Range -> 416 with Content-Range: bytes */size
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["clip.mp4", "bytes=10-12", 10],
|
||||
["clip.mp4", "bytes=10-", 10],
|
||||
["clip.mp4", "bytes=5-2", 10],
|
||||
["clip.mp4", "bytes=-0", 10],
|
||||
["empty.bin", "bytes=0-0", 0],
|
||||
] as const,
|
||||
([file, range, total]) =>
|
||||
Effect.gen(function* () {
|
||||
const unsatisfiable = yield* request(file, { headers: { Range: range } })
|
||||
expect({ file, range, status: unsatisfiable.status }).toEqual({ file, range, status: 416 })
|
||||
expect(unsatisfiable.headers.get("content-range")).toBe(`bytes */${total}`)
|
||||
expect(yield* Effect.promise(() => unsatisfiable.text())).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
// 5. Multi-range and malformed Range headers fall back to full 200
|
||||
yield* Effect.forEach(
|
||||
["bytes=0-1, 4-5", "items=0-3", "bytes=", "bytes=-", "bytes=abc-def", "bytes=0-1-2"] as const,
|
||||
(range) =>
|
||||
Effect.gen(function* () {
|
||||
const fallback = yield* request("clip.mp4", { headers: { Range: range } })
|
||||
expect({ range, status: fallback.status }).toEqual({ range, status: 200 })
|
||||
expect(fallback.headers.get("content-range")).toBeNull()
|
||||
expect(fallback.headers.get("content-length")).toBe("10")
|
||||
expect(yield* Effect.promise(() => fallback.text())).toBe("0123456789")
|
||||
}),
|
||||
)
|
||||
|
||||
// 6. Conditional requests: If-None-Match & If-Modified-Since -> 304 Not Modified
|
||||
if (!etag || !lastModified) return yield* Effect.die(new Error("Missing ETag or Last-Modified"))
|
||||
const strongEtag = etag.slice(2)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["If-None-Match", etag],
|
||||
["If-None-Match", strongEtag],
|
||||
["If-None-Match", `"other", ${etag}`],
|
||||
["If-None-Match", "*"],
|
||||
["If-Modified-Since", lastModified],
|
||||
] as const,
|
||||
([header, value]) =>
|
||||
Effect.gen(function* () {
|
||||
const notModified = yield* request("clip.mp4", { headers: { [header]: value } })
|
||||
expect(notModified.status).toBe(304)
|
||||
expect(notModified.headers.get("etag")).toBe(etag)
|
||||
expect(notModified.headers.get("last-modified")).toBe(lastModified)
|
||||
expect(yield* Effect.promise(() => notModified.text())).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
const modifiedSincePast = yield* request("clip.mp4", {
|
||||
headers: { "If-Modified-Since": "Wed, 01 Jan 2020 00:00:00 GMT" },
|
||||
})
|
||||
expect(modifiedSincePast.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => modifiedSincePast.text())).toBe("0123456789")
|
||||
|
||||
// Mismatched If-None-Match takes precedence over matching If-Modified-Since
|
||||
const mismatchIfNoneMatch = yield* request("clip.mp4", {
|
||||
headers: { "If-None-Match": 'W/"mismatch"', "If-Modified-Since": lastModified },
|
||||
})
|
||||
expect(mismatchIfNoneMatch.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => mismatchIfNoneMatch.text())).toBe("0123456789")
|
||||
|
||||
// 7. If-Range matching -> 206; If-Range mismatch -> full 200
|
||||
yield* Effect.forEach([etag, strongEtag, lastModified], (ifRange) =>
|
||||
Effect.gen(function* () {
|
||||
const matchedRange = yield* request("clip.mp4", {
|
||||
headers: { Range: "bytes=1-4", "If-Range": ifRange },
|
||||
})
|
||||
expect(matchedRange.status).toBe(206)
|
||||
expect(matchedRange.headers.get("content-range")).toBe("bytes 1-4/10")
|
||||
expect(yield* Effect.promise(() => matchedRange.text())).toBe("1234")
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(['W/"stale"', '"stale"', "Wed, 01 Jan 2020 00:00:00 GMT", "not-a-date"], (ifRange) =>
|
||||
Effect.gen(function* () {
|
||||
const mismatchedRange = yield* request("clip.mp4", {
|
||||
headers: { Range: "bytes=1-4", "If-Range": ifRange },
|
||||
})
|
||||
expect(mismatchedRange.status).toBe(200)
|
||||
expect(mismatchedRange.headers.get("content-range")).toBeNull()
|
||||
expect(mismatchedRange.headers.get("content-length")).toBe("10")
|
||||
expect(yield* Effect.promise(() => mismatchedRange.text())).toBe("0123456789")
|
||||
}),
|
||||
)
|
||||
|
||||
// 8. Confinement enforced (404 FileNotFoundError instead of 500)
|
||||
yield* Effect.forEach(["..%2Foutside%2Fsecret.txt", "escape-link/secret.txt", "nested"], (forbiddenPath) =>
|
||||
Effect.gen(function* () {
|
||||
const escaped = yield* request(forbiddenPath)
|
||||
expect({ forbiddenPath, status: escaped.status }).toEqual({ forbiddenPath, status: 404 })
|
||||
expect(yield* Effect.promise(() => escaped.json())).toMatchObject({
|
||||
_tag: "FileNotFoundError",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// 9. Multi-megabyte file streams byte-for-byte on full read and range slice
|
||||
const fullLarge = yield* request("large.mp4")
|
||||
expect(fullLarge.status).toBe(200)
|
||||
expect(fullLarge.headers.get("content-length")).toBe(String(largeBytes.length))
|
||||
const fullLargeReceived = new Uint8Array(yield* Effect.promise(() => fullLarge.arrayBuffer()))
|
||||
expect(fullLargeReceived.length).toBe(largeBytes.length)
|
||||
expect(Buffer.compare(fullLargeReceived, largeBytes)).toBe(0)
|
||||
|
||||
const sliceStart = 1024 * 1024 + 17
|
||||
const sliceEnd = 2 * 1024 * 1024 + 519
|
||||
const partialLarge = yield* request("large.mp4", {
|
||||
headers: { Range: `bytes=${sliceStart}-${sliceEnd}` },
|
||||
})
|
||||
expect(partialLarge.status).toBe(206)
|
||||
expect(partialLarge.headers.get("content-range")).toBe(
|
||||
`bytes ${sliceStart}-${sliceEnd}/${largeBytes.length}`,
|
||||
)
|
||||
expect(partialLarge.headers.get("content-length")).toBe(String(sliceEnd - sliceStart + 1))
|
||||
const partialLargeReceived = new Uint8Array(yield* Effect.promise(() => partialLarge.arrayBuffer()))
|
||||
expect(Buffer.compare(partialLargeReceived, largeBytes.subarray(sliceStart, sliceEnd + 1))).toBe(0)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
@@ -8481,7 +8481,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Serve one file relative to the requested location.",
|
||||
"description": "Stream one file relative to the requested location with ETag, Last-Modified, and single-range HTTP Range support (206 Partial Content, 416 Range Not Satisfiable, and 304 Not Modified; multi-range and malformed Range headers fall back to 200).",
|
||||
"summary": "Read file"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8481,7 +8481,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Serve one file relative to the requested location.",
|
||||
"description": "Stream one file relative to the requested location with ETag, Last-Modified, and single-range HTTP Range support (206 Partial Content, 416 Range Not Satisfiable, and 304 Not Modified; multi-range and malformed Range headers fall back to 200).",
|
||||
"summary": "Read file"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user