mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-23 09:30:00 +00:00
337 lines
16 KiB
TypeScript
337 lines
16 KiB
TypeScript
import fs from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { expect } from "bun:test"
|
|
import { Effect, Schedule } from "effect"
|
|
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
|
import { it } from "../../core/test/lib/effect"
|
|
import { startServer } from "./fixture/server"
|
|
|
|
it.live(
|
|
"browsing parents and siblings reuses the current Location and its MCP process",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const current = path.join(tmp.path, "root", "current")
|
|
const starts = path.join(tmp.path, "starts")
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(current, { recursive: true })
|
|
await fs.mkdir(path.join(tmp.path, "root", ".git"))
|
|
await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true })
|
|
await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside")
|
|
await fs.writeFile(starts, "")
|
|
await fs.writeFile(
|
|
path.join(tmp.path, "root", "opencode.json"),
|
|
JSON.stringify({
|
|
mcp: {
|
|
servers: {
|
|
filesystem: {
|
|
type: "local",
|
|
command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts],
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
const server = yield* startServer(path.join(tmp.path, "config"))
|
|
const list = (directory: string) =>
|
|
Effect.promise(async () => {
|
|
const url = new URL("/api/fs/list", server.base)
|
|
url.searchParams.set("location[directory]", current)
|
|
url.searchParams.set("path", directory)
|
|
const response = await fetch(url, { headers: server.headers })
|
|
expect(response.status).toBe(200)
|
|
const result = await response.json()
|
|
expect(result.location.directory).toBe(current)
|
|
return result.data
|
|
})
|
|
const loaded = Effect.promise(async () => {
|
|
const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })
|
|
expect(response.status).toBe(200)
|
|
return response.json()
|
|
})
|
|
const count = Effect.promise(
|
|
async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length,
|
|
)
|
|
|
|
yield* list(".")
|
|
expect(yield* loaded).toEqual([{ directory: current }])
|
|
expect(
|
|
yield* count.pipe(
|
|
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
|
|
Effect.timeout("5 seconds"),
|
|
),
|
|
).toBe(1)
|
|
|
|
yield* list("..")
|
|
const sibling = yield* list("../sibling")
|
|
expect(sibling).toEqual([
|
|
{ path: path.join("..", "sibling", "nested") + path.sep, type: "directory" },
|
|
{ path: path.join("..", "sibling", "file.txt"), type: "file" },
|
|
])
|
|
expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling)
|
|
yield* list("../sibling/nested")
|
|
yield* list("../sibling")
|
|
expect(yield* loaded).toEqual([{ directory: current }])
|
|
expect(yield* count).toBe(1)
|
|
}),
|
|
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(current, "notes.txt"), "compressible ".repeat(1024))
|
|
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?: BunFetchRequestInit) =>
|
|
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()
|
|
expect(full.headers.get("cache-control")).toBe("no-cache, no-transform")
|
|
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("cache-control")).toBe("no-cache, no-transform")
|
|
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(notModified.headers.get("cache-control")).toBe("no-cache, no-transform")
|
|
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. Compressible files keep their exact Content-Length instead of being gzipped
|
|
const text = yield* request("notes.txt", { headers: { "Accept-Encoding": "gzip" }, decompress: false })
|
|
expect(text.status).toBe(200)
|
|
expect(text.headers.get("content-encoding")).toBeNull()
|
|
expect(text.headers.get("content-length")).toBe(String("compressible ".length * 1024))
|
|
expect(yield* Effect.promise(() => text.text())).toBe("compressible ".repeat(1024))
|
|
|
|
// 10. 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,
|
|
)
|
|
|
|
it.live("opens no file for HEAD and closes it when a download is aborted", () =>
|
|
Effect.gen(function* () {
|
|
if (process.platform !== "linux" && process.platform !== "darwin") return
|
|
const tmp = yield* tmpdirScoped()
|
|
const file = path.join(tmp.path, "large.bin")
|
|
yield* Effect.promise(() => fs.writeFile(file, new Uint8Array(32 * 1024 * 1024)))
|
|
const real = yield* Effect.promise(() => fs.realpath(file))
|
|
const server = yield* startServer(path.join(tmp.path, "config"))
|
|
const url = new URL("/api/fs/read/large.bin", server.base)
|
|
url.searchParams.set("location[directory]", tmp.path)
|
|
const openHandles = Effect.promise(async () => {
|
|
if (process.platform === "darwin")
|
|
return Bun.spawnSync(["lsof", "-p", String(process.pid), "-Fn"])
|
|
.stdout.toString()
|
|
.split("\n")
|
|
.filter((line) => line === `n${real}`).length
|
|
const fds = await fs.readdir("/proc/self/fd")
|
|
const targets = await Promise.all(fds.map((fd) => fs.readlink(`/proc/self/fd/${fd}`).catch(() => "")))
|
|
return targets.filter((target) => target === real).length
|
|
})
|
|
|
|
const head = yield* Effect.promise(() => fetch(url, { method: "HEAD", headers: server.headers }))
|
|
expect(head.status).toBe(200)
|
|
expect(head.headers.get("content-length")).toBe(String(32 * 1024 * 1024))
|
|
expect(yield* openHandles).toBe(0)
|
|
|
|
const controller = new AbortController()
|
|
const download = yield* Effect.promise(() => fetch(url, { headers: server.headers, signal: controller.signal }))
|
|
const reader = download.body?.getReader()
|
|
yield* Effect.promise(async () => reader?.read())
|
|
expect(yield* openHandles).toBe(1)
|
|
controller.abort()
|
|
expect(
|
|
yield* openHandles.pipe(
|
|
Effect.repeat({ until: (count) => count === 0, schedule: Schedule.spaced("25 millis") }),
|
|
Effect.timeout("5 seconds"),
|
|
),
|
|
).toBe(0)
|
|
}),
|
|
)
|