156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
export * as LocationMutation from "./location-mutation"
|
|
|
|
import path from "path"
|
|
import { Context, Effect, Layer, Schema } from "effect"
|
|
import { FSUtil } from "./fs-util"
|
|
import { Location } from "./location"
|
|
|
|
export const Kind = Schema.Literals(["file", "directory"])
|
|
export type Kind = typeof Kind.Type
|
|
|
|
/**
|
|
* Mutation paths do not accept project references. Relative paths must stay
|
|
* inside the active Location. Absolute paths outside it require separate
|
|
* `external_directory` approval.
|
|
*/
|
|
export const ResolveInput = Schema.Struct({
|
|
path: Schema.String,
|
|
/** Selects the external approval boundary; it does not validate the target type. */
|
|
kind: Kind.pipe(Schema.optional),
|
|
})
|
|
export type ResolveInput = typeof ResolveInput.Type
|
|
|
|
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
|
path: Schema.String,
|
|
reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
|
|
}) {}
|
|
|
|
export interface ExternalDirectoryAuthorization {
|
|
readonly action: "external_directory"
|
|
/** Canonical existing directory used as the external approval boundary. */
|
|
readonly directory: string
|
|
/** `external_directory` permission resource. */
|
|
readonly resource: string
|
|
readonly save: string
|
|
}
|
|
|
|
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
|
action: input.action,
|
|
resources: [input.resource],
|
|
save: [input.save],
|
|
})
|
|
|
|
export interface Target {
|
|
/** Canonical existing path, or missing path below a canonical directory. */
|
|
readonly canonical: string
|
|
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
|
readonly resource: string
|
|
readonly externalDirectory?: ExternalDirectoryAuthorization
|
|
}
|
|
|
|
export interface Interface {
|
|
/**
|
|
* Resolve a path and derive its permission resources. Relative paths must
|
|
* stay inside the Location. Absolute paths outside it require separate
|
|
* `external_directory` approval. This does not approve the mutation.
|
|
*/
|
|
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
|
|
|
|
interface ResolvedPath {
|
|
readonly canonical: string
|
|
readonly type?:
|
|
| "File"
|
|
| "Directory"
|
|
| "SymbolicLink"
|
|
| "BlockDevice"
|
|
| "CharacterDevice"
|
|
| "FIFO"
|
|
| "Socket"
|
|
| "Unknown"
|
|
readonly directory: string
|
|
}
|
|
|
|
const slash = (value: string) => value.replaceAll("\\", "/")
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const location = yield* Location.Service
|
|
const locationRoot = yield* fs.realPath(location.directory)
|
|
|
|
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
|
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
|
}
|
|
|
|
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
|
const existing = yield* notFound(fs.realPath(absolute))
|
|
if (existing !== undefined) {
|
|
const info = yield* fs.stat(existing)
|
|
return {
|
|
canonical: existing,
|
|
type: info.type,
|
|
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
|
} satisfies ResolvedPath
|
|
}
|
|
|
|
let anchor = path.dirname(absolute)
|
|
while (true) {
|
|
const canonical = yield* notFound(fs.realPath(anchor))
|
|
if (canonical !== undefined) {
|
|
const info = yield* fs.stat(canonical)
|
|
if (info.type !== "Directory") {
|
|
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
}
|
|
return {
|
|
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
|
directory: canonical,
|
|
} satisfies ResolvedPath
|
|
}
|
|
const parent = path.dirname(anchor)
|
|
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
anchor = parent
|
|
}
|
|
})
|
|
|
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
|
const relative = !path.isAbsolute(input.path)
|
|
const absolute = path.resolve(location.directory, input.path)
|
|
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
|
if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
|
|
|
|
const resolved = yield* resolvePath(absolute)
|
|
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
|
return yield* new PathError({ path: input.path, reason: "location_escape" })
|
|
}
|
|
|
|
const external = !lexicallyInternal
|
|
const resource = external
|
|
? slash(resolved.canonical)
|
|
: slash(path.relative(locationRoot, resolved.canonical) || ".")
|
|
const externalDirectory =
|
|
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
|
return {
|
|
canonical: resolved.canonical,
|
|
resource,
|
|
externalDirectory: external
|
|
? {
|
|
action: "external_directory",
|
|
directory: externalDirectory,
|
|
resource: externalResource,
|
|
save: externalResource,
|
|
}
|
|
: undefined,
|
|
} satisfies Target
|
|
})
|
|
|
|
return Service.of({ resolve })
|
|
}),
|
|
)
|
|
|
|
export const locationLayer = layer
|