location-mutation.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect, test } from "bun:test"
  4. import { Effect, Layer, Schema } from "effect"
  5. import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
  6. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { tmpdir } from "./fixture/tmpdir"
  11. import { location } from "./fixture/location"
  12. import { it } from "./lib/effect"
  13. function provide(directory: string) {
  14. return Effect.provide(
  15. LayerNode.compile(
  16. LayerNode.bind(
  17. LocationMutation.node,
  18. Location.node,
  19. makeLocationNode({
  20. service: Location.Service,
  21. layer: Layer.succeed(
  22. Location.Service,
  23. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  24. ),
  25. deps: [],
  26. }),
  27. ),
  28. ),
  29. )
  30. }
  31. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  32. return Effect.acquireRelease(
  33. Effect.promise(() => tmpdir()),
  34. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  35. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  36. }
  37. describe("LocationMutation", () => {
  38. it.live("resolves an active relative existing file target", () =>
  39. withTmp((directory) =>
  40. Effect.gen(function* () {
  41. const targetPath = path.join(directory, "hello.txt")
  42. yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
  43. const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
  44. expect(target).toMatchObject({
  45. canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
  46. resource: "hello.txt",
  47. })
  48. expect(target.externalDirectory).toBeUndefined()
  49. }).pipe(provide(directory)),
  50. ),
  51. )
  52. it.live("resolves an active relative prospective file target", () =>
  53. withTmp((directory) =>
  54. Effect.gen(function* () {
  55. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  56. const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
  57. const root = yield* Effect.promise(() => fs.realpath(directory))
  58. expect(target).toMatchObject({
  59. canonical: path.join(root, "src", "new.txt"),
  60. resource: "src/new.txt",
  61. })
  62. }).pipe(provide(directory)),
  63. ),
  64. )
  65. it.live("rejects a relative lexical escape instead of promoting it to external authority", () =>
  66. withTmp((directory) =>
  67. Effect.gen(function* () {
  68. const error = yield* Effect.flip((yield* LocationMutation.Service).resolve({ path: "../outside.txt" }))
  69. expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "relative_escape" })
  70. }).pipe(provide(directory)),
  71. ),
  72. )
  73. it.live("rejects a prospective target below an escaping symlink ancestor", () =>
  74. withTmp((directory) => {
  75. const outside = `${directory}-outside`
  76. return Effect.gen(function* () {
  77. if (process.platform === "win32") return
  78. yield* Effect.promise(async () => {
  79. await fs.mkdir(outside)
  80. await fs.symlink(outside, path.join(directory, "escape"))
  81. })
  82. const error = yield* Effect.flip(
  83. (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }),
  84. )
  85. expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" })
  86. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  87. }).pipe(provide(directory))
  88. }),
  89. )
  90. it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
  91. withTmp((directory) =>
  92. Effect.gen(function* () {
  93. if (process.platform === "win32") return
  94. yield* Effect.promise(async () => {
  95. await fs.mkdir(path.join(directory, "actual"))
  96. await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
  97. })
  98. expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
  99. canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
  100. resource: "actual/new.txt",
  101. })
  102. }).pipe(provide(directory)),
  103. ),
  104. )
  105. it.live("accepts an explicit absolute in-location target without external approval", () =>
  106. withTmp((directory) =>
  107. Effect.gen(function* () {
  108. const targetPath = path.join(directory, "new.txt")
  109. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  110. expect(target).toMatchObject({
  111. canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
  112. resource: "new.txt",
  113. })
  114. expect(target.externalDirectory).toBeUndefined()
  115. }).pipe(provide(directory)),
  116. ),
  117. )
  118. it.live("requires external-directory authorization for an explicit external absolute target", () =>
  119. withTmp((directory) =>
  120. withTmp((outside) =>
  121. Effect.gen(function* () {
  122. const targetPath = path.join(outside, "new.txt")
  123. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  124. const root = yield* Effect.promise(() => fs.realpath(outside))
  125. expect(target).toMatchObject({
  126. canonical: path.join(root, "new.txt"),
  127. resource: path.join(root, "new.txt").replaceAll("\\", "/"),
  128. })
  129. expect(target.externalDirectory).toMatchObject({
  130. directory: root,
  131. resource: path.join(root, "*").replaceAll("\\", "/"),
  132. })
  133. }).pipe(provide(directory)),
  134. ),
  135. ),
  136. )
  137. it.live("resolves an existing external file target", () =>
  138. withTmp((directory) =>
  139. withTmp((outside) =>
  140. Effect.gen(function* () {
  141. const targetPath = path.join(outside, "existing.txt")
  142. yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
  143. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  144. const root = yield* Effect.promise(() => fs.realpath(outside))
  145. expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
  146. expect(target.externalDirectory?.directory).toBe(root)
  147. }).pipe(provide(directory)),
  148. ),
  149. ),
  150. )
  151. it.live("anchors prospective external descendants at their stable existing directory", () =>
  152. withTmp((directory) =>
  153. withTmp((outside) =>
  154. Effect.gen(function* () {
  155. const targetPath = path.join(outside, "new", "nested", "file.txt")
  156. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  157. const root = yield* Effect.promise(() => fs.realpath(outside))
  158. expect(target.externalDirectory).toMatchObject({
  159. directory: root,
  160. resource: path.join(root, "*").replaceAll("\\", "/"),
  161. })
  162. }).pipe(provide(directory)),
  163. ),
  164. ),
  165. )
  166. test("ignores unknown mutation input fields", () => {
  167. expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
  168. expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
  169. path: "README.md",
  170. })
  171. })
  172. })