location-mutation.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. export * as LocationMutation from "./location-mutation"
  2. import { makeLocationNode } from "./effect/app-node"
  3. import path from "path"
  4. import { Context, Effect, Layer, Schema } from "effect"
  5. import { FSUtil } from "./fs-util"
  6. import { Location } from "./location"
  7. export const Kind = Schema.Literals(["file", "directory"])
  8. export type Kind = typeof Kind.Type
  9. /**
  10. * Mutation paths do not accept project references. Relative paths must stay
  11. * inside the active Location. Absolute paths outside it require separate
  12. * `external_directory` approval.
  13. */
  14. export const ResolveInput = Schema.Struct({
  15. path: Schema.String,
  16. /** Selects the external approval boundary; it does not validate the target type. */
  17. kind: Kind.pipe(Schema.optional),
  18. })
  19. export type ResolveInput = typeof ResolveInput.Type
  20. export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
  21. path: Schema.String,
  22. reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
  23. }) {}
  24. export interface ExternalDirectoryAuthorization {
  25. readonly action: "external_directory"
  26. /** Canonical existing directory used as the external approval boundary. */
  27. readonly directory: string
  28. /** `external_directory` permission resource. */
  29. readonly resource: string
  30. readonly save: string
  31. }
  32. export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
  33. action: input.action,
  34. resources: [input.resource],
  35. save: [input.save],
  36. })
  37. export interface Target {
  38. /** Canonical existing path, or missing path below a canonical directory. */
  39. readonly canonical: string
  40. /** Permission resource: Location-relative for internal paths, canonical for external paths. */
  41. readonly resource: string
  42. readonly externalDirectory?: ExternalDirectoryAuthorization
  43. }
  44. export interface Interface {
  45. /**
  46. * Resolve a path and derive its permission resources. Relative paths must
  47. * stay inside the Location. Absolute paths outside it require separate
  48. * `external_directory` approval. This does not approve the mutation.
  49. */
  50. readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
  51. }
  52. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
  53. interface ResolvedPath {
  54. readonly canonical: string
  55. readonly type?:
  56. | "File"
  57. | "Directory"
  58. | "SymbolicLink"
  59. | "BlockDevice"
  60. | "CharacterDevice"
  61. | "FIFO"
  62. | "Socket"
  63. | "Unknown"
  64. readonly directory: string
  65. }
  66. const slash = (value: string) => value.replaceAll("\\", "/")
  67. const layer = Layer.effect(
  68. Service,
  69. Effect.gen(function* () {
  70. const fs = yield* FSUtil.Service
  71. const location = yield* Location.Service
  72. const locationRoot = yield* fs.realPath(location.directory)
  73. function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
  74. return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
  75. }
  76. const resolvePath = Effect.fnUntraced(function* (absolute: string) {
  77. const existing = yield* notFound(fs.realPath(absolute))
  78. if (existing !== undefined) {
  79. const info = yield* fs.stat(existing)
  80. return {
  81. canonical: existing,
  82. type: info.type,
  83. directory: info.type === "Directory" ? existing : path.dirname(existing),
  84. } satisfies ResolvedPath
  85. }
  86. let anchor = path.dirname(absolute)
  87. while (true) {
  88. const canonical = yield* notFound(fs.realPath(anchor))
  89. if (canonical !== undefined) {
  90. const info = yield* fs.stat(canonical)
  91. if (info.type !== "Directory") {
  92. return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
  93. }
  94. return {
  95. canonical: path.resolve(canonical, path.relative(anchor, absolute)),
  96. directory: canonical,
  97. } satisfies ResolvedPath
  98. }
  99. const parent = path.dirname(anchor)
  100. if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
  101. anchor = parent
  102. }
  103. })
  104. const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
  105. const relative = !path.isAbsolute(input.path)
  106. const absolute = path.resolve(location.directory, input.path)
  107. const lexicallyInternal = FSUtil.contains(location.directory, absolute)
  108. if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
  109. const resolved = yield* resolvePath(absolute)
  110. if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
  111. return yield* new PathError({ path: input.path, reason: "location_escape" })
  112. }
  113. const external = !lexicallyInternal
  114. const resource = external
  115. ? slash(resolved.canonical)
  116. : slash(path.relative(locationRoot, resolved.canonical) || ".")
  117. const externalDirectory =
  118. input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
  119. const externalResource = slash(path.join(externalDirectory, "*"))
  120. return {
  121. canonical: resolved.canonical,
  122. resource,
  123. externalDirectory: external
  124. ? {
  125. action: "external_directory",
  126. directory: externalDirectory,
  127. resource: externalResource,
  128. save: externalResource,
  129. }
  130. : undefined,
  131. } satisfies Target
  132. })
  133. return Service.of({ resolve })
  134. }),
  135. )
  136. export const locationLayer = layer
  137. export const node = makeLocationNode({
  138. service: Service,
  139. layer: layer.pipe(Layer.orDie),
  140. deps: [FSUtil.node, Location.node],
  141. })