location-filesystem.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  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, Fiber, 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 { Global } from "@opencode-ai/core/global"
  13. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { location } from "./fixture/location"
  16. import { it } from "./lib/effect"
  17. const inertReferences = ProjectReference.Service.of({
  18. list: () => Effect.succeed([]),
  19. get: () => Effect.succeed(undefined),
  20. resolveMention: () => Effect.succeed(undefined),
  21. ensurePath: () => Effect.void,
  22. containsManagedPath: () => Effect.succeed(false),
  23. })
  24. function provide(
  25. directory: string,
  26. references = inertReferences,
  27. filesystem = FSUtil.defaultLayer,
  28. data = Global.Path.data,
  29. ) {
  30. return Effect.provide(
  31. FileSystem.layer.pipe(
  32. Layer.provide(
  33. Layer.mergeAll(
  34. filesystem,
  35. Ripgrep.defaultLayer,
  36. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  37. Layer.succeed(ProjectReference.Service, references),
  38. Global.layerWith({ data }),
  39. ),
  40. ),
  41. ),
  42. )
  43. }
  44. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  45. return Effect.acquireRelease(
  46. Effect.promise(() => tmpdir()),
  47. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  48. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  49. }
  50. describe("FileSystem", () => {
  51. it.live("accepts generated managed output paths and rejects other absolute paths", () =>
  52. withTmp((directory) => {
  53. const worktree = directory
  54. const data = path.join(directory, "data")
  55. return Effect.gen(function* () {
  56. const managed = path.join(data, "tool-output")
  57. const output = path.join(managed, "tool_123")
  58. const unrelated = path.join(directory, "secret.txt")
  59. yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
  60. yield* Effect.promise(() => fs.writeFile(output, "failure here"))
  61. yield* Effect.promise(() => fs.writeFile(unrelated, "secret"))
  62. const service = yield* FileSystem.Service
  63. expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
  64. expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
  65. expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
  66. expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
  67. }).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
  68. }),
  69. )
  70. it.live("reads text and binary files", () =>
  71. withTmp((directory) =>
  72. Effect.gen(function* () {
  73. yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
  74. yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
  75. const service = yield* FileSystem.Service
  76. expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
  77. type: "text",
  78. content: "hello",
  79. mime: "text/plain",
  80. })
  81. expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
  82. type: "binary",
  83. content: "AAEC",
  84. encoding: "base64",
  85. mime: "application/octet-stream",
  86. })
  87. const binary = yield* service.resolveRead({ path: RelativePath.make("data.bin") })
  88. expect(Exit.isFailure(yield* service.readTextPageResolved(binary).pipe(Effect.exit))).toBe(true)
  89. }).pipe(provide(directory)),
  90. ),
  91. )
  92. it.live("revalidates file identity before sampled classification", () =>
  93. withTmp((directory) =>
  94. Effect.gen(function* () {
  95. const file = path.join(directory, "image.png")
  96. yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47])))
  97. const service = yield* FileSystem.Service
  98. const target = yield* service.resolveRead({ path: RelativePath.make("image.png") })
  99. yield* Effect.promise(() => fs.rename(file, path.join(directory, "original.png")))
  100. yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0xff, 0xd8, 0xff])))
  101. expect(
  102. Exit.isFailure(yield* service.readSampleResolved(target, FileSystem.READ_SAMPLE_BYTES).pipe(Effect.exit)),
  103. ).toBe(true)
  104. }).pipe(provide(directory)),
  105. ),
  106. )
  107. it.live("pages large UTF-8 text files by line with continuation", () =>
  108. withTmp((directory) =>
  109. Effect.gen(function* () {
  110. const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}-é`.padEnd(2_000, "x"))
  111. yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n")))
  112. const service = yield* FileSystem.Service
  113. const target = yield* service.resolveRead({ path: RelativePath.make("large.txt") })
  114. const first = yield* service.readTextPageResolved(target)
  115. expect(first).toMatchObject({
  116. type: "text-page",
  117. offset: 1,
  118. truncated: true,
  119. })
  120. expect(first.next).toBeDefined()
  121. const next = first.next!
  122. expect(yield* service.readTextPageResolved(target, { offset: next, limit: 1 })).toEqual({
  123. type: "text-page",
  124. content: lines[next - 1],
  125. mime: "text/plain",
  126. offset: next,
  127. truncated: true,
  128. next: next + 1,
  129. })
  130. expect(yield* service.readTextPageResolved(target, { offset: 30 })).toEqual({
  131. type: "text-page",
  132. content: lines[29],
  133. mime: "text/plain",
  134. offset: 30,
  135. truncated: false,
  136. })
  137. }).pipe(provide(directory)),
  138. ),
  139. )
  140. it.live("rejects paged text when a late NUL appears after the requested page", () =>
  141. withTmp((directory) =>
  142. Effect.gen(function* () {
  143. const file = path.join(directory, "late-binary.txt")
  144. yield* Effect.promise(() =>
  145. fs.writeFile(
  146. file,
  147. Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0])]),
  148. ),
  149. )
  150. const service = yield* FileSystem.Service
  151. const target = yield* service.resolveRead({ path: RelativePath.make("late-binary.txt") })
  152. expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true)
  153. }).pipe(provide(directory)),
  154. ),
  155. )
  156. it.live("rejects paged text when invalid UTF-8 appears near EOF", () =>
  157. withTmp((directory) =>
  158. Effect.gen(function* () {
  159. const file = path.join(directory, "invalid-utf8.txt")
  160. yield* Effect.promise(() =>
  161. fs.writeFile(
  162. file,
  163. Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0xc3, 0x28])]),
  164. ),
  165. )
  166. const service = yield* FileSystem.Service
  167. const target = yield* service.resolveRead({ path: RelativePath.make("invalid-utf8.txt") })
  168. expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true)
  169. }).pipe(provide(directory)),
  170. ),
  171. )
  172. it.live("rejects PDFs for direct, large, and paged reads", () =>
  173. withTmp((directory) =>
  174. Effect.gen(function* () {
  175. const small = path.join(directory, "small.pdf")
  176. const large = path.join(directory, "large.pdf")
  177. yield* Effect.promise(() => fs.writeFile(small, "%PDF-1.7\nsmall"))
  178. yield* Effect.promise(() =>
  179. fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])),
  180. )
  181. const service = yield* FileSystem.Service
  182. const smallTarget = yield* service.resolveRead({ path: RelativePath.make("small.pdf") })
  183. const largeTarget = yield* service.resolveRead({ path: RelativePath.make("large.pdf") })
  184. expect(Exit.isFailure(yield* service.readToolResolved(smallTarget).pipe(Effect.exit))).toBe(true)
  185. expect(Exit.isFailure(yield* service.readToolResolved(largeTarget).pipe(Effect.exit))).toBe(true)
  186. expect(Exit.isFailure(yield* service.readToolResolved(largeTarget, { limit: 1 }).pipe(Effect.exit))).toBe(true)
  187. }).pipe(provide(directory)),
  188. ),
  189. )
  190. it.live("rejects signature-bearing media beyond the ingestion cap before loading", () =>
  191. withTmp((directory) =>
  192. Effect.gen(function* () {
  193. const file = path.join(directory, "huge.png")
  194. yield* Effect.promise(async () => {
  195. const handle = await fs.open(file, "w")
  196. try {
  197. await handle.write(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0, 8, 0)
  198. await handle.truncate(FileSystem.MAX_MEDIA_INGEST_BYTES + 1)
  199. } finally {
  200. await handle.close()
  201. }
  202. })
  203. const service = yield* FileSystem.Service
  204. const target = yield* service.resolveRead({ path: RelativePath.make("huge.png") })
  205. const exit = yield* service.readToolResolved(target).pipe(Effect.exit)
  206. expect(Exit.isFailure(exit)).toBe(true)
  207. if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("Media exceeds")
  208. }).pipe(provide(directory)),
  209. ),
  210. )
  211. it.live("never mixes a sampled image with replacement-path content", () =>
  212. withTmp((directory) =>
  213. Effect.gen(function* () {
  214. const file = path.join(directory, "race.png")
  215. const moved = path.join(directory, "original.png")
  216. const original = Buffer.concat([
  217. Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  218. Buffer.alloc(4 * 1024 * 1024, 0x11),
  219. ])
  220. const replacement = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(1024, 0x22)])
  221. yield* Effect.promise(() => fs.writeFile(file, original))
  222. const service = yield* FileSystem.Service
  223. const target = yield* service.resolveRead({ path: RelativePath.make("race.png") })
  224. const reading = yield* service.readToolResolved(target).pipe(Effect.forkChild)
  225. yield* Effect.promise(async () => {
  226. await fs.rename(file, moved)
  227. await fs.writeFile(file, replacement)
  228. })
  229. const exit = yield* Fiber.join(reading).pipe(Effect.exit)
  230. if (Exit.isSuccess(exit)) {
  231. expect(exit.value).toMatchObject({ type: "binary", mime: "image/png" })
  232. if (exit.value.type === "binary") expect(exit.value.content).toBe(original.toString("base64"))
  233. }
  234. }).pipe(provide(directory)),
  235. ),
  236. )
  237. it.live("closes validated descriptors after successful and failed reads", () =>
  238. withTmp((directory) => {
  239. let active = 0
  240. const filesystem = Layer.effect(
  241. FSUtil.Service,
  242. Effect.gen(function* () {
  243. const service = yield* FSUtil.Service
  244. return FSUtil.Service.of({
  245. ...service,
  246. open: (target, options) =>
  247. Effect.acquireRelease(
  248. service.open(target, options).pipe(Effect.tap(() => Effect.sync(() => active++))),
  249. () => Effect.sync(() => active--),
  250. ),
  251. })
  252. }),
  253. ).pipe(Layer.provide(FSUtil.defaultLayer))
  254. return Effect.gen(function* () {
  255. const text = path.join(directory, "text.txt")
  256. const binary = path.join(directory, "binary.pdf")
  257. yield* Effect.promise(() => fs.writeFile(text, "hello"))
  258. yield* Effect.promise(() => fs.writeFile(binary, "%PDF-1.7"))
  259. const service = yield* FileSystem.Service
  260. const before =
  261. process.platform === "win32"
  262. ? undefined
  263. : yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
  264. for (let index = 0; index < 50; index++) {
  265. yield* service.readToolResolved(yield* service.resolveRead({ path: RelativePath.make("text.txt") }))
  266. yield* service
  267. .readToolResolved(yield* service.resolveRead({ path: RelativePath.make("binary.pdf") }))
  268. .pipe(Effect.exit)
  269. }
  270. expect(active).toBe(0)
  271. if (before !== undefined) {
  272. const after = yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
  273. expect(after).toBeLessThanOrEqual(before + 2)
  274. }
  275. yield* Effect.promise(() => fs.rename(text, text + ".moved"))
  276. yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
  277. }).pipe(provide(directory, inertReferences, filesystem))
  278. }),
  279. )
  280. it.live("lists direct children with relative paths and resolved URIs", () =>
  281. withTmp((directory) =>
  282. Effect.gen(function* () {
  283. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  284. yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
  285. const service = yield* FileSystem.Service
  286. const entries = yield* service.list()
  287. expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
  288. {
  289. path: RelativePath.make("src"),
  290. type: "directory",
  291. mime: "application/x-directory",
  292. },
  293. {
  294. path: RelativePath.make("README.md"),
  295. type: "file",
  296. mime: "text/markdown",
  297. },
  298. ])
  299. expect(
  300. yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
  301. ).toEqual(
  302. yield* Effect.promise(() =>
  303. Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
  304. ),
  305. )
  306. }).pipe(provide(directory)),
  307. ),
  308. )
  309. it.live("lists stable bounded pages", () =>
  310. withTmp((directory) =>
  311. Effect.gen(function* () {
  312. yield* Effect.promise(async () => {
  313. await fs.mkdir(path.join(directory, "src"))
  314. await fs.writeFile(path.join(directory, "README.md"), "# Test")
  315. })
  316. const service = yield* FileSystem.Service
  317. expect(yield* service.listPage({ limit: 1 })).toMatchObject({
  318. entries: [{ path: "src", type: "directory" }],
  319. truncated: true,
  320. next: 2,
  321. })
  322. expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
  323. entries: [{ path: "README.md", type: "file" }],
  324. truncated: false,
  325. })
  326. expect((yield* service.resolveList()).resource).toBe(".")
  327. }).pipe(provide(directory)),
  328. ),
  329. )
  330. it.live("materializes only the selected direct children for a page", () =>
  331. withTmp((directory) => {
  332. const realPaths: string[] = []
  333. const filesystem = Layer.effect(
  334. FSUtil.Service,
  335. Effect.gen(function* () {
  336. const service = yield* FSUtil.Service
  337. return FSUtil.Service.of({
  338. ...service,
  339. realPath: (target) =>
  340. Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))),
  341. })
  342. }),
  343. ).pipe(Layer.provide(FSUtil.defaultLayer))
  344. return Effect.gen(function* () {
  345. yield* Effect.promise(async () => {
  346. await fs.mkdir(path.join(directory, "src"))
  347. await fs.writeFile(path.join(directory, "alpha.txt"), "alpha")
  348. await fs.writeFile(path.join(directory, "beta.txt"), "beta")
  349. })
  350. const service = yield* FileSystem.Service
  351. expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
  352. entries: [{ path: "alpha.txt", type: "file" }],
  353. truncated: true,
  354. next: 3,
  355. })
  356. expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
  357. }).pipe(provide(directory, inertReferences, filesystem))
  358. }),
  359. )
  360. it.live("materializes selected page entries with at most 16 concurrent real path lookups", () =>
  361. withTmp((directory) => {
  362. let active = 0
  363. let maximum = 0
  364. const filesystem = Layer.effect(
  365. FSUtil.Service,
  366. Effect.gen(function* () {
  367. const service = yield* FSUtil.Service
  368. return FSUtil.Service.of({
  369. ...service,
  370. realPath: (target) =>
  371. target === directory
  372. ? service.realPath(target)
  373. : Effect.acquireUseRelease(
  374. Effect.sync(() => {
  375. active++
  376. maximum = Math.max(maximum, active)
  377. }),
  378. () => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))),
  379. () => Effect.sync(() => active--),
  380. ),
  381. })
  382. }),
  383. ).pipe(Layer.provide(FSUtil.defaultLayer))
  384. return Effect.gen(function* () {
  385. yield* Effect.promise(() =>
  386. Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))),
  387. )
  388. const service = yield* FileSystem.Service
  389. expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
  390. expect(maximum).toBe(16)
  391. }).pipe(provide(directory, inertReferences, filesystem))
  392. }),
  393. )
  394. it.live("caps direct list page service calls at 2000 entries", () =>
  395. withTmp((directory) =>
  396. Effect.gen(function* () {
  397. yield* Effect.promise(() =>
  398. Promise.all(
  399. Array.from({ length: 2_001 }, (_, index) =>
  400. fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""),
  401. ),
  402. ),
  403. )
  404. const service = yield* FileSystem.Service
  405. const target = yield* service.resolveList()
  406. expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000)
  407. }).pipe(provide(directory)),
  408. ),
  409. )
  410. test("rejects empty list aliases and page limits over 2000", () => {
  411. const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
  412. expect(() => decode({ reference: "" })).toThrow()
  413. expect(() => decode({ limit: 2_001 })).toThrow()
  414. })
  415. it.live("rejects escaping list paths and omits escaping symlink children", () =>
  416. withTmp((directory) =>
  417. Effect.gen(function* () {
  418. if (process.platform === "win32") return
  419. const outside = `${directory}-outside`
  420. yield* Effect.promise(async () => {
  421. await fs.mkdir(outside)
  422. await fs.writeFile(path.join(outside, "secret.txt"), "secret")
  423. await fs.symlink(outside, path.join(directory, "escape"))
  424. })
  425. const service = yield* FileSystem.Service
  426. expect(
  427. Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)),
  428. ).toBe(true)
  429. expect((yield* service.listPage()).entries).toEqual([])
  430. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  431. }).pipe(provide(directory)),
  432. ),
  433. )
  434. it.live("paginates visible entries after omitting escaping symlink children", () =>
  435. withTmp((directory) =>
  436. Effect.gen(function* () {
  437. if (process.platform === "win32") return
  438. const outside = `${directory}-outside`
  439. yield* Effect.promise(async () => {
  440. await fs.mkdir(outside)
  441. await fs.symlink(outside, path.join(directory, "a-escape"))
  442. await fs.writeFile(path.join(directory, "b-visible.txt"), "visible")
  443. })
  444. const service = yield* FileSystem.Service
  445. expect(yield* service.listPage({ limit: 1 })).toMatchObject({
  446. entries: [{ path: "b-visible.txt", type: "file" }],
  447. truncated: false,
  448. })
  449. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  450. }).pipe(provide(directory)),
  451. ),
  452. )
  453. it.live("rejects paths outside the location", () =>
  454. withTmp((directory) =>
  455. Effect.gen(function* () {
  456. const service = yield* FileSystem.Service
  457. expect(
  458. Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
  459. ).toBe(true)
  460. }).pipe(provide(directory)),
  461. ),
  462. )
  463. it.live("reads and lists paths relative to a local project reference", () =>
  464. withTmp((directory) => {
  465. const docs = path.join(directory, "docs")
  466. return Effect.gen(function* () {
  467. yield* Effect.promise(async () => {
  468. await fs.mkdir(docs)
  469. await fs.writeFile(path.join(docs, "README.md"), "docs")
  470. })
  471. const service = yield* FileSystem.Service
  472. expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
  473. type: "text",
  474. content: "docs",
  475. })
  476. expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
  477. }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
  478. }),
  479. )
  480. it.live("materializes Git references before filesystem access", () =>
  481. withTmp((directory) => {
  482. const docs = path.join(directory, "docs")
  483. const ensured: string[] = []
  484. return Effect.gen(function* () {
  485. yield* Effect.promise(async () => {
  486. await fs.mkdir(docs)
  487. await fs.writeFile(path.join(docs, "README.md"), "docs")
  488. })
  489. expect(
  490. yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
  491. ).toMatchObject({ content: "docs" })
  492. expect(ensured).toEqual([docs])
  493. }).pipe(
  494. provide(
  495. directory,
  496. references(
  497. {
  498. sdk: {
  499. name: "sdk",
  500. kind: "git",
  501. repository: "owner/repo",
  502. reference: Repository.parseRemote("owner/repo"),
  503. path: docs,
  504. },
  505. },
  506. (target) => Effect.sync(() => ensured.push(target ?? "")),
  507. ),
  508. ),
  509. )
  510. }),
  511. )
  512. it.live("rejects unknown, invalid, and escaping project reference paths", () =>
  513. withTmp((directory) => {
  514. const docs = path.join(directory, "docs")
  515. return Effect.gen(function* () {
  516. yield* Effect.promise(() => fs.mkdir(docs))
  517. const service = yield* FileSystem.Service
  518. expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
  519. expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
  520. expect(
  521. Exit.isFailure(
  522. yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
  523. ),
  524. ).toBe(true)
  525. }).pipe(
  526. provide(
  527. directory,
  528. references({
  529. docs: { name: "docs", kind: "local", path: docs },
  530. invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
  531. }),
  532. ),
  533. )
  534. }),
  535. )
  536. it.live("rejects aliases when project references are disabled", () =>
  537. withTmp((directory) =>
  538. Effect.gen(function* () {
  539. expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
  540. true,
  541. )
  542. }).pipe(provide(directory)),
  543. ),
  544. )
  545. it.live("rejects symlink escapes from project references", () =>
  546. withTmp((directory) => {
  547. const docs = path.join(directory, "docs")
  548. const outside = path.join(directory, "outside.txt")
  549. return Effect.gen(function* () {
  550. if (process.platform === "win32") return
  551. yield* Effect.promise(async () => {
  552. await fs.mkdir(docs)
  553. await fs.writeFile(outside, "outside")
  554. await fs.symlink(outside, path.join(docs, "link.txt"))
  555. })
  556. expect(
  557. Exit.isFailure(
  558. yield* (yield* FileSystem.Service)
  559. .read({ reference: "docs", path: RelativePath.make("link.txt") })
  560. .pipe(Effect.exit),
  561. ),
  562. ).toBe(true)
  563. }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
  564. }),
  565. )
  566. })
  567. function references(
  568. entries: Record<string, ProjectReference.Resolved>,
  569. ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
  570. ) {
  571. return ProjectReference.Service.of({
  572. list: () => Effect.succeed(Object.values(entries)),
  573. get: (name) => Effect.succeed(entries[name]),
  574. resolveMention: () => Effect.succeed(undefined),
  575. ensurePath,
  576. containsManagedPath: () => Effect.succeed(false),
  577. })
  578. }