location-mutation.test.ts 7.1 KB

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