location-filesystem.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { fileURLToPath } from "url"
  4. import { describe, expect, test } from "bun:test"
  5. import { Effect, Exit, Layer, Schema } from "effect"
  6. import { FSUtil } from "@opencode-ai/core/fs-util"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { FileSystem } from "@opencode-ai/core/filesystem"
  9. import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
  10. import { ProjectReference } from "@opencode-ai/core/project-reference"
  11. import { Repository } from "@opencode-ai/core/repository"
  12. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { location } from "./fixture/location"
  15. import { it } from "./lib/effect"
  16. const inertReferences = ProjectReference.Service.of({
  17. list: () => Effect.succeed([]),
  18. get: () => Effect.succeed(undefined),
  19. resolveMention: () => Effect.succeed(undefined),
  20. ensurePath: () => Effect.void,
  21. containsManagedPath: () => Effect.succeed(false),
  22. })
  23. function provide(directory: string, references = inertReferences, filesystem = FSUtil.defaultLayer) {
  24. return Effect.provide(
  25. FileSystem.layer.pipe(
  26. Layer.provide(
  27. Layer.mergeAll(
  28. filesystem,
  29. Ripgrep.defaultLayer,
  30. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  31. Layer.succeed(ProjectReference.Service, references),
  32. ),
  33. ),
  34. ),
  35. )
  36. }
  37. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  38. return Effect.acquireRelease(
  39. Effect.promise(() => tmpdir()),
  40. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  41. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  42. }
  43. describe("FileSystem", () => {
  44. it.live("reads text and binary files", () =>
  45. withTmp((directory) =>
  46. Effect.gen(function* () {
  47. yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
  48. yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
  49. const service = yield* FileSystem.Service
  50. expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
  51. type: "text",
  52. content: "hello",
  53. mime: "text/plain",
  54. })
  55. expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
  56. type: "binary",
  57. content: "AAEC",
  58. encoding: "base64",
  59. mime: "application/octet-stream",
  60. })
  61. const binary = yield* service.resolveRead({ path: RelativePath.make("data.bin") })
  62. expect(Exit.isFailure(yield* service.readTextPageResolved(binary).pipe(Effect.exit))).toBe(true)
  63. }).pipe(provide(directory)),
  64. ),
  65. )
  66. it.live("pages large UTF-8 text files by line with continuation", () =>
  67. withTmp((directory) =>
  68. Effect.gen(function* () {
  69. const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}-é`.padEnd(2_000, "x"))
  70. yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n")))
  71. const service = yield* FileSystem.Service
  72. const target = yield* service.resolveRead({ path: RelativePath.make("large.txt") })
  73. const first = yield* service.readTextPageResolved(target)
  74. expect(first).toMatchObject({
  75. type: "text-page",
  76. offset: 1,
  77. truncated: true,
  78. })
  79. expect(first.next).toBeDefined()
  80. const next = first.next!
  81. expect(yield* service.readTextPageResolved(target, { offset: next, limit: 1 })).toEqual({
  82. type: "text-page",
  83. content: lines[next - 1],
  84. mime: "text/plain",
  85. offset: next,
  86. truncated: true,
  87. next: next + 1,
  88. })
  89. expect(yield* service.readTextPageResolved(target, { offset: 30 })).toEqual({
  90. type: "text-page",
  91. content: lines[29],
  92. mime: "text/plain",
  93. offset: 30,
  94. truncated: false,
  95. })
  96. }).pipe(provide(directory)),
  97. ),
  98. )
  99. it.live("lists direct children with relative paths and resolved URIs", () =>
  100. withTmp((directory) =>
  101. Effect.gen(function* () {
  102. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  103. yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
  104. const service = yield* FileSystem.Service
  105. const entries = yield* service.list()
  106. expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
  107. {
  108. path: RelativePath.make("src"),
  109. type: "directory",
  110. mime: "application/x-directory",
  111. },
  112. {
  113. path: RelativePath.make("README.md"),
  114. type: "file",
  115. mime: "text/markdown",
  116. },
  117. ])
  118. expect(
  119. yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
  120. ).toEqual(
  121. yield* Effect.promise(() =>
  122. Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
  123. ),
  124. )
  125. }).pipe(provide(directory)),
  126. ),
  127. )
  128. it.live("lists stable bounded pages", () =>
  129. withTmp((directory) =>
  130. Effect.gen(function* () {
  131. yield* Effect.promise(async () => {
  132. await fs.mkdir(path.join(directory, "src"))
  133. await fs.writeFile(path.join(directory, "README.md"), "# Test")
  134. })
  135. const service = yield* FileSystem.Service
  136. expect(yield* service.listPage({ limit: 1 })).toMatchObject({
  137. entries: [{ path: "src", type: "directory" }],
  138. truncated: true,
  139. next: 2,
  140. })
  141. expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
  142. entries: [{ path: "README.md", type: "file" }],
  143. truncated: false,
  144. })
  145. expect((yield* service.resolveList()).resource).toBe(".")
  146. }).pipe(provide(directory)),
  147. ),
  148. )
  149. it.live("materializes only the selected direct children for a page", () =>
  150. withTmp((directory) => {
  151. const realPaths: string[] = []
  152. const filesystem = Layer.effect(
  153. FSUtil.Service,
  154. Effect.gen(function* () {
  155. const service = yield* FSUtil.Service
  156. return FSUtil.Service.of({
  157. ...service,
  158. realPath: (target) =>
  159. Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))),
  160. })
  161. }),
  162. ).pipe(Layer.provide(FSUtil.defaultLayer))
  163. return Effect.gen(function* () {
  164. yield* Effect.promise(async () => {
  165. await fs.mkdir(path.join(directory, "src"))
  166. await fs.writeFile(path.join(directory, "alpha.txt"), "alpha")
  167. await fs.writeFile(path.join(directory, "beta.txt"), "beta")
  168. })
  169. const service = yield* FileSystem.Service
  170. expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
  171. entries: [{ path: "alpha.txt", type: "file" }],
  172. truncated: true,
  173. next: 3,
  174. })
  175. expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
  176. }).pipe(provide(directory, inertReferences, filesystem))
  177. }),
  178. )
  179. it.live("materializes selected page entries with at most 16 concurrent real path lookups", () =>
  180. withTmp((directory) => {
  181. let active = 0
  182. let maximum = 0
  183. const filesystem = Layer.effect(
  184. FSUtil.Service,
  185. Effect.gen(function* () {
  186. const service = yield* FSUtil.Service
  187. return FSUtil.Service.of({
  188. ...service,
  189. realPath: (target) =>
  190. target === directory
  191. ? service.realPath(target)
  192. : Effect.acquireUseRelease(
  193. Effect.sync(() => {
  194. active++
  195. maximum = Math.max(maximum, active)
  196. }),
  197. () => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))),
  198. () => Effect.sync(() => active--),
  199. ),
  200. })
  201. }),
  202. ).pipe(Layer.provide(FSUtil.defaultLayer))
  203. return Effect.gen(function* () {
  204. yield* Effect.promise(() =>
  205. Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))),
  206. )
  207. const service = yield* FileSystem.Service
  208. expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
  209. expect(maximum).toBe(16)
  210. }).pipe(provide(directory, inertReferences, filesystem))
  211. }),
  212. )
  213. it.live("caps direct list page service calls at 2000 entries", () =>
  214. withTmp((directory) =>
  215. Effect.gen(function* () {
  216. yield* Effect.promise(() =>
  217. Promise.all(
  218. Array.from({ length: 2_001 }, (_, index) =>
  219. fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""),
  220. ),
  221. ),
  222. )
  223. const service = yield* FileSystem.Service
  224. const target = yield* service.resolveList()
  225. expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000)
  226. }).pipe(provide(directory)),
  227. ),
  228. )
  229. test("rejects empty list aliases and page limits over 2000", () => {
  230. const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
  231. expect(() => decode({ reference: "" })).toThrow()
  232. expect(() => decode({ limit: 2_001 })).toThrow()
  233. })
  234. it.live("rejects escaping list paths and omits escaping symlink children", () =>
  235. withTmp((directory) =>
  236. Effect.gen(function* () {
  237. if (process.platform === "win32") return
  238. const outside = `${directory}-outside`
  239. yield* Effect.promise(async () => {
  240. await fs.mkdir(outside)
  241. await fs.writeFile(path.join(outside, "secret.txt"), "secret")
  242. await fs.symlink(outside, path.join(directory, "escape"))
  243. })
  244. const service = yield* FileSystem.Service
  245. expect(
  246. Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)),
  247. ).toBe(true)
  248. expect((yield* service.listPage()).entries).toEqual([])
  249. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  250. }).pipe(provide(directory)),
  251. ),
  252. )
  253. it.live("paginates visible entries after omitting escaping symlink children", () =>
  254. withTmp((directory) =>
  255. Effect.gen(function* () {
  256. if (process.platform === "win32") return
  257. const outside = `${directory}-outside`
  258. yield* Effect.promise(async () => {
  259. await fs.mkdir(outside)
  260. await fs.symlink(outside, path.join(directory, "a-escape"))
  261. await fs.writeFile(path.join(directory, "b-visible.txt"), "visible")
  262. })
  263. const service = yield* FileSystem.Service
  264. expect(yield* service.listPage({ limit: 1 })).toMatchObject({
  265. entries: [{ path: "b-visible.txt", type: "file" }],
  266. truncated: false,
  267. })
  268. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  269. }).pipe(provide(directory)),
  270. ),
  271. )
  272. it.live("rejects paths outside the location", () =>
  273. withTmp((directory) =>
  274. Effect.gen(function* () {
  275. const service = yield* FileSystem.Service
  276. expect(
  277. Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
  278. ).toBe(true)
  279. }).pipe(provide(directory)),
  280. ),
  281. )
  282. it.live("reads and lists paths relative to a local project reference", () =>
  283. withTmp((directory) => {
  284. const docs = path.join(directory, "docs")
  285. return Effect.gen(function* () {
  286. yield* Effect.promise(async () => {
  287. await fs.mkdir(docs)
  288. await fs.writeFile(path.join(docs, "README.md"), "docs")
  289. })
  290. const service = yield* FileSystem.Service
  291. expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
  292. type: "text",
  293. content: "docs",
  294. })
  295. expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
  296. }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
  297. }),
  298. )
  299. it.live("materializes Git references before filesystem access", () =>
  300. withTmp((directory) => {
  301. const docs = path.join(directory, "docs")
  302. const ensured: string[] = []
  303. return Effect.gen(function* () {
  304. yield* Effect.promise(async () => {
  305. await fs.mkdir(docs)
  306. await fs.writeFile(path.join(docs, "README.md"), "docs")
  307. })
  308. expect(
  309. yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
  310. ).toMatchObject({ content: "docs" })
  311. expect(ensured).toEqual([docs])
  312. }).pipe(
  313. provide(
  314. directory,
  315. references(
  316. {
  317. sdk: {
  318. name: "sdk",
  319. kind: "git",
  320. repository: "owner/repo",
  321. reference: Repository.parseRemote("owner/repo"),
  322. path: docs,
  323. },
  324. },
  325. (target) => Effect.sync(() => ensured.push(target ?? "")),
  326. ),
  327. ),
  328. )
  329. }),
  330. )
  331. it.live("rejects unknown, invalid, and escaping project reference paths", () =>
  332. withTmp((directory) => {
  333. const docs = path.join(directory, "docs")
  334. return Effect.gen(function* () {
  335. yield* Effect.promise(() => fs.mkdir(docs))
  336. const service = yield* FileSystem.Service
  337. expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
  338. expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
  339. expect(
  340. Exit.isFailure(
  341. yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
  342. ),
  343. ).toBe(true)
  344. }).pipe(
  345. provide(
  346. directory,
  347. references({
  348. docs: { name: "docs", kind: "local", path: docs },
  349. invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
  350. }),
  351. ),
  352. )
  353. }),
  354. )
  355. it.live("rejects aliases when project references are disabled", () =>
  356. withTmp((directory) =>
  357. Effect.gen(function* () {
  358. expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
  359. true,
  360. )
  361. }).pipe(provide(directory)),
  362. ),
  363. )
  364. it.live("rejects symlink escapes from project references", () =>
  365. withTmp((directory) => {
  366. const docs = path.join(directory, "docs")
  367. const outside = path.join(directory, "outside.txt")
  368. return Effect.gen(function* () {
  369. if (process.platform === "win32") return
  370. yield* Effect.promise(async () => {
  371. await fs.mkdir(docs)
  372. await fs.writeFile(outside, "outside")
  373. await fs.symlink(outside, path.join(docs, "link.txt"))
  374. })
  375. expect(
  376. Exit.isFailure(
  377. yield* (yield* FileSystem.Service)
  378. .read({ reference: "docs", path: RelativePath.make("link.txt") })
  379. .pipe(Effect.exit),
  380. ),
  381. ).toBe(true)
  382. }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
  383. }),
  384. )
  385. })
  386. function references(
  387. entries: Record<string, ProjectReference.Resolved>,
  388. ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
  389. ) {
  390. return ProjectReference.Service.of({
  391. list: () => Effect.succeed(Object.values(entries)),
  392. get: (name) => Effect.succeed(entries[name]),
  393. resolveMention: () => Effect.succeed(undefined),
  394. ensurePath,
  395. containsManagedPath: () => Effect.succeed(false),
  396. })
  397. }