location-mutation.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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 { FSUtil } from "@opencode-ai/core/fs-util"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  8. import { AbsolutePath } from "@opencode-ai/core/schema"
  9. import { tmpdir } from "./fixture/tmpdir"
  10. import { location } from "./fixture/location"
  11. import { it } from "./lib/effect"
  12. function provide(directory: string) {
  13. return Effect.provide(
  14. LocationMutation.layer.pipe(
  15. Layer.provide(
  16. Layer.mergeAll(
  17. FSUtil.defaultLayer,
  18. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  19. ),
  20. ),
  21. ),
  22. )
  23. }
  24. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  25. return Effect.acquireRelease(
  26. Effect.promise(() => tmpdir()),
  27. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  28. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  29. }
  30. describe("LocationMutation", () => {
  31. it.live("resolves an active relative existing file target", () =>
  32. withTmp((directory) =>
  33. Effect.gen(function* () {
  34. const targetPath = path.join(directory, "hello.txt")
  35. yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
  36. const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
  37. expect(plan.target).toMatchObject({
  38. canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
  39. exists: true,
  40. resource: "hello.txt",
  41. })
  42. expect(plan.target.externalDirectory).toBeUndefined()
  43. expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
  44. canonical: plan.target.canonical,
  45. })
  46. }).pipe(provide(directory)),
  47. ),
  48. )
  49. it.live("resolves an active relative prospective file target", () =>
  50. withTmp((directory) =>
  51. Effect.gen(function* () {
  52. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  53. const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
  54. const root = yield* Effect.promise(() => fs.realpath(directory))
  55. expect(plan.target).toMatchObject({
  56. canonical: path.join(root, "src", "new.txt"),
  57. exists: false,
  58. resource: "src/new.txt",
  59. })
  60. expect(plan.authority.canonical).toBe(path.join(root, "src"))
  61. expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
  62. canonical: plan.target.canonical,
  63. })
  64. }).pipe(provide(directory)),
  65. ),
  66. )
  67. it.live("rejects a relative lexical escape instead of promoting it to external authority", () =>
  68. withTmp((directory) =>
  69. Effect.gen(function* () {
  70. const error = yield* Effect.flip((yield* LocationMutation.Service).resolve({ path: "../outside.txt" }))
  71. expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "relative_escape" })
  72. }).pipe(provide(directory)),
  73. ),
  74. )
  75. it.live("rejects a prospective target below an escaping symlink ancestor", () =>
  76. withTmp((directory) => {
  77. const outside = `${directory}-outside`
  78. return Effect.gen(function* () {
  79. if (process.platform === "win32") return
  80. yield* Effect.promise(async () => {
  81. await fs.mkdir(outside)
  82. await fs.symlink(outside, path.join(directory, "escape"))
  83. })
  84. const error = yield* Effect.flip(
  85. (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }),
  86. )
  87. expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" })
  88. yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
  89. }).pipe(provide(directory))
  90. }),
  91. )
  92. it.live("accepts an explicit absolute in-location target without external approval", () =>
  93. withTmp((directory) =>
  94. Effect.gen(function* () {
  95. const targetPath = path.join(directory, "new.txt")
  96. const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  97. expect(plan.target).toMatchObject({
  98. canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
  99. resource: "new.txt",
  100. })
  101. expect(plan.target.externalDirectory).toBeUndefined()
  102. }).pipe(provide(directory)),
  103. ),
  104. )
  105. it.live("requires external-directory authorization for an explicit external absolute target", () =>
  106. withTmp((directory) =>
  107. withTmp((outside) =>
  108. Effect.gen(function* () {
  109. const targetPath = path.join(outside, "new.txt")
  110. const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  111. const root = yield* Effect.promise(() => fs.realpath(outside))
  112. expect(plan.target).toMatchObject({
  113. canonical: path.join(root, "new.txt"),
  114. resource: path.join(root, "new.txt").replaceAll("\\", "/"),
  115. })
  116. expect(plan.target.externalDirectory).toMatchObject({
  117. directory: root,
  118. resource: path.join(root, "*").replaceAll("\\", "/"),
  119. })
  120. }).pipe(provide(directory)),
  121. ),
  122. ),
  123. )
  124. it.live("resolves an existing external file target", () =>
  125. withTmp((directory) =>
  126. withTmp((outside) =>
  127. Effect.gen(function* () {
  128. const targetPath = path.join(outside, "existing.txt")
  129. yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
  130. const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  131. const root = yield* Effect.promise(() => fs.realpath(outside))
  132. expect(plan.target).toMatchObject({ canonical: path.join(root, "existing.txt"), exists: true })
  133. expect(plan.authority.canonical).toBe(path.join(root, "existing.txt"))
  134. expect(plan.target.externalDirectory?.directory).toBe(root)
  135. }).pipe(provide(directory)),
  136. ),
  137. ),
  138. )
  139. it.live("anchors prospective external descendants at their stable existing directory", () =>
  140. withTmp((directory) =>
  141. withTmp((outside) =>
  142. Effect.gen(function* () {
  143. const targetPath = path.join(outside, "new", "nested", "file.txt")
  144. const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  145. const root = yield* Effect.promise(() => fs.realpath(outside))
  146. expect(plan.authority.canonical).toBe(root)
  147. expect(plan.target.externalDirectory).toMatchObject({
  148. directory: root,
  149. resource: path.join(root, "*").replaceAll("\\", "/"),
  150. })
  151. }).pipe(provide(directory)),
  152. ),
  153. ),
  154. )
  155. it.live("rejects a symlink-ancestor swap during post-approval revalidation", () =>
  156. withTmp((directory) =>
  157. withTmp((outside) =>
  158. Effect.gen(function* () {
  159. if (process.platform === "win32") return
  160. const parent = path.join(directory, "parent")
  161. yield* Effect.promise(() => fs.mkdir(parent))
  162. const service = yield* LocationMutation.Service
  163. const plan = yield* service.resolve({ path: path.join("parent", "new.txt") })
  164. yield* Effect.promise(async () => {
  165. await fs.rmdir(parent)
  166. await fs.symlink(outside, parent)
  167. })
  168. const error = yield* Effect.flip(service.revalidate(plan))
  169. expect(error).toMatchObject({ _tag: "LocationMutation.RevalidationError" })
  170. }).pipe(provide(directory)),
  171. ),
  172. ),
  173. )
  174. it.live("rejects an existing target identity swap during post-approval revalidation", () =>
  175. withTmp((directory) =>
  176. Effect.gen(function* () {
  177. const targetPath = path.join(directory, "existing.txt")
  178. yield* Effect.promise(() => fs.writeFile(targetPath, "first"))
  179. const service = yield* LocationMutation.Service
  180. const plan = yield* service.resolve({ path: "existing.txt" })
  181. yield* Effect.promise(async () => {
  182. const replacementPath = path.join(directory, "replacement.txt")
  183. await fs.writeFile(replacementPath, "second")
  184. await fs.rm(targetPath)
  185. await fs.rename(replacementPath, targetPath)
  186. })
  187. const error = yield* Effect.flip(service.revalidate(plan))
  188. expect(error).toMatchObject({
  189. _tag: "LocationMutation.RevalidationError",
  190. reason: "mutation authority changed",
  191. })
  192. }).pipe(provide(directory)),
  193. ),
  194. )
  195. it.live("rejects a nearer prospective ancestor introduced after approval", () =>
  196. withTmp((directory) =>
  197. Effect.gen(function* () {
  198. const service = yield* LocationMutation.Service
  199. const plan = yield* service.resolve({ path: path.join("new", "nested", "file.txt") })
  200. yield* Effect.promise(() => fs.mkdir(path.join(directory, "new")))
  201. const error = yield* Effect.flip(service.revalidate(plan))
  202. expect(error).toMatchObject({
  203. _tag: "LocationMutation.RevalidationError",
  204. reason: "mutation authority changed",
  205. })
  206. }).pipe(provide(directory)),
  207. ),
  208. )
  209. test("keeps project references outside the mutation input API", () => {
  210. expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
  211. expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
  212. path: "README.md",
  213. })
  214. })
  215. })