location-mutation.test.ts 7.1 KB

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