mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-26 05:10:01 +00:00
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { describe, expect } from "bun:test"
|
|
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"
|
|
import { Location } from "@opencode/core/location"
|
|
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
|
import { Workspace } from "@opencode/core/workspace"
|
|
import { location } from "./fixture/location"
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
import { it } from "./lib/effect"
|
|
|
|
const provide = (directory: string, workspaceID?: Workspace.ID) =>
|
|
Effect.provide(
|
|
LayerNode.compile(FileSystem.node, {
|
|
replacements: [
|
|
Location.node.replace(
|
|
Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
|
|
),
|
|
),
|
|
],
|
|
}),
|
|
)
|
|
|
|
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
|
Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
|
|
|
describe("FileSystem", () => {
|
|
it.live("reads text and binary files", () =>
|
|
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, 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(yield* Stream.mkUint8Array(text.stream()))).toBe("hello")
|
|
expect(text.mime).toBe("text/plain")
|
|
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)),
|
|
),
|
|
)
|
|
|
|
it.live("lists direct children", () =>
|
|
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 filesystem = yield* FileSystem.Service
|
|
const entries = yield* filesystem.list()
|
|
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
|
{ path: RelativePath.make("src" + path.sep), type: "directory" },
|
|
{ path: RelativePath.make("README.md"), type: "file" },
|
|
])
|
|
}).pipe(provide(directory)),
|
|
),
|
|
)
|
|
|
|
it.live("skips host canonicalization for workspace locations at boot", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
// The directory exists only inside the workspace, so boot must not
|
|
// require it to exist on the host. Operations still access the host
|
|
// filesystem per call (#44568); only boot canonicalization is skipped.
|
|
const missing = path.join(directory, "workspace-only")
|
|
const workspace = yield* FileSystem.Service.pipe(
|
|
provide(missing, Workspace.ID.make("wrk_filesystem")),
|
|
Effect.exit,
|
|
)
|
|
expect(Exit.isSuccess(workspace)).toBe(true)
|
|
|
|
// A local ref with the same missing directory keeps failing boot:
|
|
// host realpath canonicalization stays load-bearing for local placements.
|
|
const local = yield* FileSystem.Service.pipe(provide(missing), Effect.exit)
|
|
expect(Exit.isFailure(local)).toBe(true)
|
|
if (Exit.isFailure(local)) {
|
|
const error = Cause.findErrorOption(local.cause)
|
|
expect(error).toMatchObject({
|
|
_tag: "Some",
|
|
value: {
|
|
_tag: "FileSystem.DirectoryNotFoundError",
|
|
directory: missing,
|
|
message: `Directory not found: ${missing}`,
|
|
},
|
|
})
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
for (const input of [
|
|
{ reason: "PermissionDenied", code: "EACCES", denied: true },
|
|
{ reason: "Unknown", code: "EPERM", denied: true },
|
|
{ reason: "Unknown", code: "EIO", denied: false },
|
|
] as const) {
|
|
it.live(`classifies directory initialization failure ${input.code}`, () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const filesystem = yield* FSUtil.Service
|
|
const cause = PlatformError.systemError({
|
|
_tag: input.reason,
|
|
module: "FileSystem",
|
|
method: "realPath",
|
|
pathOrDescriptor: directory,
|
|
cause: Object.assign(new Error(input.code), { code: input.code }),
|
|
})
|
|
const result = yield* FileSystem.Service.pipe(
|
|
Effect.provide(
|
|
LayerNode.compile(FileSystem.node, {
|
|
replacements: [
|
|
Location.node.replace(
|
|
Layer.succeed(Location.Service, location({ directory: AbsolutePath.make(directory) })),
|
|
),
|
|
FSUtil.node.replace(
|
|
Layer.succeed(FSUtil.Service, {
|
|
...filesystem,
|
|
realPath: (target) => (target === directory ? Effect.fail(cause) : filesystem.realPath(target)),
|
|
}),
|
|
),
|
|
],
|
|
}),
|
|
),
|
|
Effect.exit,
|
|
)
|
|
expect(Exit.isFailure(result)).toBe(true)
|
|
if (Exit.isFailure(result)) {
|
|
if (input.denied) {
|
|
expect(Cause.findErrorOption(result.cause)).toMatchObject({
|
|
_tag: "Some",
|
|
value: {
|
|
_tag: "FileSystem.DirectoryAccessDeniedError",
|
|
directory,
|
|
cause,
|
|
message: `Access denied to directory: ${directory}`,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
expect(result.cause.reasons.filter(Cause.isDieReason)).toMatchObject([{ defect: cause }])
|
|
}
|
|
}).pipe(Effect.provide(LayerNode.compile(FSUtil.node))),
|
|
),
|
|
)
|
|
}
|
|
|
|
it.live("lists parents and siblings with paths relative to the current location", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const current = path.join(directory, "current")
|
|
yield* Effect.promise(() => fs.mkdir(current))
|
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
|
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
|
|
yield* Effect.gen(function* () {
|
|
const filesystem = yield* FileSystem.Service
|
|
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
|
|
expect(parent).toHaveLength(2)
|
|
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
|
|
expect.arrayContaining([
|
|
{ path: "." + path.sep, type: "directory" },
|
|
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
|
|
]),
|
|
)
|
|
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
|
|
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
|
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
|
|
])
|
|
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
|
|
expect(absolute).toEqual(sibling)
|
|
const missing = yield* Effect.flip(filesystem.list({ path: RelativePath.make("missing") }))
|
|
expect(missing).toBeInstanceOf(FileSystem.NotFoundError)
|
|
const file = yield* Effect.flip(filesystem.list({ path: RelativePath.make("../sibling/file.txt") }))
|
|
expect(file).toBeInstanceOf(FileSystem.NotFoundError)
|
|
}).pipe(provide(current))
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("canonicalizes local symlinked directories", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const real = path.join(directory, "real")
|
|
yield* Effect.promise(() => fs.mkdir(real))
|
|
yield* Effect.promise(() => fs.writeFile(path.join(real, "file.txt"), "linked"))
|
|
const link = path.join(directory, "link")
|
|
yield* Effect.promise(() => fs.symlink(real, link))
|
|
// Reads resolve through the symlink only because boot canonicalized
|
|
// 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)).toBe("linked")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects lexical escapes", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const current = path.join(directory, "current")
|
|
yield* Effect.promise(() => fs.mkdir(current))
|
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
|
|
yield* Effect.gen(function* () {
|
|
const filesystem = yield* FileSystem.Service
|
|
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
|
expect(Exit.isFailure(result)).toBe(true)
|
|
}).pipe(provide(current))
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("allows listing through an external symlink without allowing file reads", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const current = path.join(directory, "current")
|
|
const outside = path.join(directory, "outside")
|
|
yield* Effect.promise(() => fs.mkdir(current))
|
|
yield* Effect.promise(() => fs.mkdir(outside))
|
|
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
|
|
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
|
|
yield* Effect.gen(function* () {
|
|
const filesystem = yield* FileSystem.Service
|
|
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
|
|
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
|
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
|
|
])
|
|
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
|
|
expect(Exit.isFailure(result)).toBe(true)
|
|
}).pipe(provide(current))
|
|
}),
|
|
),
|
|
)
|
|
})
|