refactor(core): simplify filesystem mutation protocol (#31059)
This commit is contained in:
parent
54f4974546
commit
ceccde7e84
13 changed files with 356 additions and 623 deletions
|
|
@ -1,7 +1,7 @@
|
|||
export * as LocationMutation from "./location-mutation"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
|
||||
|
|
@ -22,30 +22,9 @@ 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",
|
||||
"unresolved_symlink",
|
||||
"location_identity_changed",
|
||||
]),
|
||||
reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
|
||||
}) {}
|
||||
|
||||
export class RevalidationError extends Schema.TaggedErrorClass<RevalidationError>()(
|
||||
"LocationMutation.RevalidationError",
|
||||
{
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface Identity {
|
||||
/** Canonical path for this saved filesystem identity. */
|
||||
readonly canonical: string
|
||||
readonly dev: number
|
||||
readonly ino?: number
|
||||
}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Canonical existing directory used as the external approval boundary. */
|
||||
|
|
@ -53,11 +32,8 @@ export interface ExternalDirectoryAuthorization {
|
|||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
/** Saved identity checked again after approval to detect swaps. */
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
/** Build the `external_directory` permission request. */
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
|
|
@ -67,7 +43,24 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
|||
export interface Target {
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
/** 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"
|
||||
|
|
@ -77,51 +70,7 @@ export interface Target {
|
|||
| "FIFO"
|
||||
| "Socket"
|
||||
| "Unknown"
|
||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
/**
|
||||
* A path checked before permission approval.
|
||||
*
|
||||
* resolve(path) -> Plan -> approve -> revalidate(plan) -> mutate immediately
|
||||
*
|
||||
* Tools must approve `target.externalDirectory`, when present, and their normal
|
||||
* mutation action before calling `revalidate`. Revalidation rejects escapes,
|
||||
* symlinks in missing suffixes, and changes made while approval is pending. It
|
||||
* cannot be atomic with the next filesystem call, so mutate immediately afterward.
|
||||
*/
|
||||
export interface Plan {
|
||||
readonly input: ResolveInput
|
||||
readonly target: Target
|
||||
/** Saved identity of the existing target or nearest existing ancestor. */
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Check a path before approval 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 tool's
|
||||
* mutation action.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Plan, PathError | FSUtil.Error>
|
||||
/**
|
||||
* Check the plan again immediately before mutation. Reject changes to the
|
||||
* target, its saved identity, or approval resources. Mutate the returned
|
||||
* target immediately.
|
||||
*/
|
||||
readonly revalidate: (plan: Plan) => Effect.Effect<Target, RevalidationError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
|
||||
|
||||
interface ResolvedPath {
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
readonly type?: Target["type"]
|
||||
readonly authority: Identity
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
|
@ -132,76 +81,19 @@ export const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const locationRoot = yield* fs.realPath(location.directory)
|
||||
const locationAuthority = yield* identity(locationRoot)
|
||||
|
||||
function identityFrom(canonical: string, info: Effect.Success<ReturnType<typeof fs.stat>>): Identity {
|
||||
return {
|
||||
canonical,
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
}
|
||||
}
|
||||
|
||||
function identity(canonical: string) {
|
||||
return fs.stat(canonical).pipe(Effect.map((info) => identityFrom(canonical, info)))
|
||||
}
|
||||
|
||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
function sameIdentity(left: Identity, right: Identity) {
|
||||
return left.canonical === right.canonical && left.dev === right.dev && left.ino === right.ino
|
||||
}
|
||||
|
||||
/** Check whether a saved path still points to the same filesystem object. */
|
||||
const assertIdentity = Effect.fnUntraced(function* (expected: Identity) {
|
||||
const canonical = yield* notFound(fs.realPath(expected.canonical))
|
||||
if (canonical === undefined) return false
|
||||
const actual = yield* notFound(identity(canonical))
|
||||
if (actual === undefined) return false
|
||||
return canonical === expected.canonical && sameIdentity(expected, actual)
|
||||
})
|
||||
|
||||
const assertLocationIdentity = Effect.fnUntraced(function* (requested: string) {
|
||||
if (yield* assertIdentity(locationAuthority)) return
|
||||
return yield* new PathError({ path: requested, reason: "location_identity_changed" })
|
||||
})
|
||||
|
||||
const hasUnresolvedSymlink = Effect.fnUntraced(function* (anchor: string, suffix: string) {
|
||||
let current = anchor
|
||||
for (const part of suffix.split(path.sep)) {
|
||||
if (!part) continue
|
||||
current = path.join(current, part)
|
||||
if (
|
||||
yield* fs.readLink(current).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve a path to a canonical target and save an existing filesystem
|
||||
* identity for later revalidation.
|
||||
*
|
||||
* existing path -> save target identity
|
||||
* missing path -> save nearest existing directory identity
|
||||
*
|
||||
* Missing suffixes must not contain symlinks.
|
||||
*/
|
||||
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,
|
||||
exists: true,
|
||||
type: info.type,
|
||||
authority: identityFrom(existing, info),
|
||||
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
|
||||
|
|
@ -210,16 +102,12 @@ export const layer = Layer.effect(
|
|||
const canonical = yield* notFound(fs.realPath(anchor))
|
||||
if (canonical !== undefined) {
|
||||
const info = yield* fs.stat(canonical)
|
||||
if (info.type !== "Directory")
|
||||
if (info.type !== "Directory") {
|
||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
const suffix = path.relative(anchor, absolute)
|
||||
if (yield* hasUnresolvedSymlink(anchor, suffix)) {
|
||||
return yield* new PathError({ path: absolute, reason: "unresolved_symlink" })
|
||||
}
|
||||
return {
|
||||
canonical: path.resolve(canonical, suffix),
|
||||
exists: false,
|
||||
authority: identityFrom(canonical, info),
|
||||
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
||||
directory: canonical,
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
const parent = path.dirname(anchor)
|
||||
|
|
@ -228,30 +116,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Choose the existing directory used for separate external approval.
|
||||
*
|
||||
* existing directory target -> "<target>/*"
|
||||
* file or missing target -> "<nearest existing parent>/*"
|
||||
*/
|
||||
const externalDirectory = Effect.fnUntraced(function* (resolved: ResolvedPath, kind: Kind) {
|
||||
const candidate =
|
||||
kind === "directory" && resolved.type === "Directory" ? resolved.canonical : path.dirname(resolved.canonical)
|
||||
const boundary = yield* resolvePath(candidate)
|
||||
const directory =
|
||||
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
|
||||
const resource = slash(path.join(directory, "*"))
|
||||
return {
|
||||
action: "external_directory" as const,
|
||||
directory,
|
||||
resource,
|
||||
save: resource,
|
||||
authority: boundary.authority,
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
yield* assertLocationIdentity(input.path)
|
||||
const relative = !path.isAbsolute(input.path)
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
|
|
@ -266,45 +131,24 @@ export const layer = Layer.effect(
|
|||
const resource = external
|
||||
? slash(resolved.canonical)
|
||||
: slash(path.relative(locationRoot, resolved.canonical) || ".")
|
||||
const target: Target = {
|
||||
const externalDirectory =
|
||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
canonical: resolved.canonical,
|
||||
exists: resolved.exists,
|
||||
type: resolved.type,
|
||||
resource,
|
||||
externalDirectory: external ? yield* externalDirectory(resolved, input.kind ?? "file") : undefined,
|
||||
}
|
||||
return { input, target, authority: resolved.authority } satisfies Plan
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: externalResource,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
/**
|
||||
* Re-resolve a plan immediately before mutation and reject any changed
|
||||
* identity, target, or approval resource. This reduces the race window but
|
||||
* cannot make the next filesystem call atomic.
|
||||
*/
|
||||
const revalidate = Effect.fn("LocationMutation.revalidate")(function* (plan: Plan) {
|
||||
const invalid = (reason: string) => new RevalidationError({ path: plan.input.path, reason })
|
||||
const fresh = yield* resolve(plan.input).pipe(
|
||||
Effect.mapError((error) => (error instanceof PathError ? invalid(error.reason) : error)),
|
||||
)
|
||||
if (!sameIdentity(fresh.authority, plan.authority)) return yield* invalid("mutation authority changed")
|
||||
if (fresh.target.canonical !== plan.target.canonical) return yield* invalid("canonical mutation target changed")
|
||||
if (fresh.target.resource !== plan.target.resource) return yield* invalid("mutation resource changed")
|
||||
if (Boolean(fresh.target.externalDirectory) !== Boolean(plan.target.externalDirectory)) {
|
||||
return yield* invalid("external directory authority changed")
|
||||
}
|
||||
if (
|
||||
fresh.target.externalDirectory &&
|
||||
plan.target.externalDirectory &&
|
||||
(fresh.target.externalDirectory.directory !== plan.target.externalDirectory.directory ||
|
||||
fresh.target.externalDirectory.resource !== plan.target.externalDirectory.resource ||
|
||||
!sameIdentity(fresh.target.externalDirectory.authority, plan.target.externalDirectory.authority))
|
||||
) {
|
||||
return yield* invalid("external directory authority changed")
|
||||
}
|
||||
return fresh.target
|
||||
})
|
||||
|
||||
return Service.of({ resolve, revalidate })
|
||||
return Service.of({ resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue