diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index ccc0db808..3adb1d08c 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `narrowInterfaceGuard` and the `NarrowingDelta` type, which derive the interface guard of a narrowing: each delta pattern is conjoined onto the base's guard at the argument position it addresses, arity and return guards are inherited verbatim, methods the delta does not name are dropped, and every method guard is asyncified for forwarding ([#1051](https://github.com/MetaMask/ocap-kernel/pull/1051)) - Add `pathUnder(segments)`, which builds an `@endo/patterns` pattern matching segment arrays under a prefix, with `..` excluded past the prefix so that traversal out of it is unrepresentable. Empty `segments` matches every `..`-free segment array ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) - Add `narrow({ name, base, delta })` and `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **Both functions throw for now** — this release fixes their signatures so callers and deltas can be written against them, and the implementations follow ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) - Add `getInterfaceMethodGuards`, `getMethodPayload`, `getGuardAt`, `buildMethodGuard`, and `asyncifyMethodGuards`, plus the `MethodGuardPayload` type, for reading an `@endo/patterns` interface guard by argument position — required arguments, then optionals, then the rest guard — and reassembling it ([#1048](https://github.com/MetaMask/ocap-kernel/pull/1048)) diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index fd3c7fe6f..320897b44 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -41,6 +41,7 @@ describe('index', () => { 'mergeDisjointRecords', 'methodArgsToStruct', 'narrow', + 'narrowInterfaceGuard', 'pathUnder', 'prettifySmallcaps', 'resolveFetchInput', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 5fdeecf58..564929233 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -8,12 +8,10 @@ export { getMethodPayload, } from './guard-algebra.ts'; export type { MethodGuardPayload } from './guard-algebra.ts'; +export { narrowInterfaceGuard } from './narrow-interface-guard.ts'; +export type { NarrowingDelta } from './narrow-interface-guard.ts'; export { join, narrow, pathUnder } from './narrowing.ts'; -export type { - JoinOptions, - NarrowOptions, - NarrowingDelta, -} from './narrowing.ts'; +export type { JoinOptions, NarrowOptions } from './narrowing.ts'; export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; export { S } from './described.ts'; diff --git a/packages/kernel-utils/src/narrow-interface-guard.test.ts b/packages/kernel-utils/src/narrow-interface-guard.test.ts new file mode 100644 index 000000000..edead8f6b --- /dev/null +++ b/packages/kernel-utils/src/narrow-interface-guard.test.ts @@ -0,0 +1,139 @@ +import { M, getMethodGuardPayload, matches } from '@endo/patterns'; +import type { InterfaceGuard } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { getInterfaceMethodGuards, getMethodPayload } from './guard-algebra.ts'; +import type { MethodGuardPayload } from './guard-algebra.ts'; +import { narrowInterfaceGuard } from './narrow-interface-guard.ts'; +import type { NarrowingDelta } from './narrow-interface-guard.ts'; + +// One fixture reaching all three positional categories. +const makeBaseGuard = (): InterfaceGuard => + M.interface('Store', { + read: M.callWhen(M.string()).optional(M.number()).returns(M.any()), + write: M.callWhen(M.string()).rest(M.number()).returns(M.any()), + drop: M.callWhen(M.string()).returns(M.any()), + }); + +const payloadOf = (guard: InterfaceGuard, method: string): MethodGuardPayload => + getMethodPayload(getInterfaceMethodGuards(guard)[method]!); + +const narrowFrom = ( + baseGuard: InterfaceGuard, + delta: NarrowingDelta, +): InterfaceGuard => + narrowInterfaceGuard({ name: 'Narrowed', baseGuard, delta }); + +describe('narrowInterfaceGuard', () => { + it('drops methods the delta does not name', () => { + const result = narrowFrom(makeBaseGuard(), { read: [] }); + + expect(Object.keys(getInterfaceMethodGuards(result))).toStrictEqual([ + 'read', + ]); + }); + + it('drops every method given an empty delta', () => { + const result = narrowFrom(makeBaseGuard(), {}); + + expect(getInterfaceMethodGuards(result)).toStrictEqual({}); + }); + + it('leaves a method as the base has it when its delta is empty', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [] }); + + expect(payloadOf(result, 'read')).toStrictEqual( + payloadOf(baseGuard, 'read'), + ); + }); + + it('conjoins a pattern onto a required argument', () => { + const result = narrowFrom(makeBaseGuard(), { read: [M.eq('a')] }); + const [argGuard] = payloadOf(result, 'read').argGuards; + + expect(matches('a', argGuard)).toBe(true); + expect(matches('b', argGuard)).toBe(false); + expect(matches(1, argGuard)).toBe(false); + }); + + it('leaves positions the delta does not reach as the base has them', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [M.eq('a')] }); + + expect(payloadOf(result, 'read').optionalArgGuards).toStrictEqual( + payloadOf(baseGuard, 'read').optionalArgGuards, + ); + }); + + it('conjoins onto an optional argument without making it required', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [undefined, M.lte(10)] }); + const { argGuards, optionalArgGuards } = payloadOf(result, 'read'); + + expect(argGuards).toStrictEqual(payloadOf(baseGuard, 'read').argGuards); + expect(matches(5, optionalArgGuards![0])).toBe(true); + expect(matches(20, optionalArgGuards![0])).toBe(false); + }); + + it('conjoins onto the rest guard past the fixed arity', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { write: [undefined, M.lte(10)] }); + const { argGuards, restArgGuard } = payloadOf(result, 'write'); + + expect(argGuards).toStrictEqual(payloadOf(baseGuard, 'write').argGuards); + expect(matches(5, restArgGuard)).toBe(true); + expect(matches(20, restArgGuard)).toBe(false); + }); + + it('inherits the return guard verbatim', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [M.eq('a')] }); + + expect(payloadOf(result, 'read').returnGuard).toBe( + payloadOf(baseGuard, 'read').returnGuard, + ); + }); + + it('asyncifies a synchronous method guard', () => { + const baseGuard = M.interface('Sync', { + read: M.call(M.string()).returns(M.any()), + }); + + const result = narrowFrom(baseGuard, { read: [] }); + const { callKind } = getMethodGuardPayload( + getInterfaceMethodGuards(result).read!, + ); + + expect(callKind).toBe('async'); + }); + + it.each([ + { + scenario: 'names a method the base does not have', + makeGuard: makeBaseGuard, + delta: { missing: [] }, + message: 'the base has no such method', + }, + { + scenario: + 'addresses a position past the arity of a base without a rest guard', + makeGuard: makeBaseGuard, + delta: { drop: [undefined, M.eq('x')] }, + message: 'the base has arity 1 and no rest guard', + }, + { + scenario: 'narrows a method the base guards by default', + makeGuard: () => M.interface('Any', {}, { defaultGuards: 'passable' }), + delta: { read: [M.eq('a')] }, + message: 'there is no guard to conjoin onto', + }, + ])('rejects a delta that $scenario', ({ makeGuard, delta, message }) => { + expect(() => narrowFrom(makeGuard(), delta)).toThrow(message); + }); +}); diff --git a/packages/kernel-utils/src/narrow-interface-guard.ts b/packages/kernel-utils/src/narrow-interface-guard.ts new file mode 100644 index 000000000..1d0ce4fc6 --- /dev/null +++ b/packages/kernel-utils/src/narrow-interface-guard.ts @@ -0,0 +1,130 @@ +import { M, getInterfaceGuardPayload } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; + +import { + buildMethodGuard, + getInterfaceMethodGuards, + getMethodPayload, +} from './guard-algebra.ts'; + +/** + * Patterns to conjoin onto a base capability's method guards, addressed by + * argument position. + * + * A method the delta does not name is dropped, so forgetting a method removes + * authority rather than granting it. Within a method, a hole leaves that + * position as the base has it, and an empty array leaves every position as the + * base has it. + */ +export type NarrowingDelta = Record; + +/** + * Conjoin a delta pattern onto a base guard. + * + * @param base - The base guard. + * @param pattern - The pattern to conjoin, or undefined at a hole. + * @returns The conjunction, or the base guard unchanged at a hole. + */ +const conjoin = (base: Pattern, pattern: Pattern | undefined): Pattern => + pattern === undefined ? base : M.and(base, pattern); + +/** + * Narrow one method guard by conjoining the delta's patterns onto the + * positions they address. + * + * Positions are walked as required arguments, then optionals, then the rest + * guard, and each stays in the category it lands in. Every position past the + * fixed arity conjoins onto the one rest guard, which is the only thing a rest + * position can express. + * + * @param methodName - The method being narrowed, for error messages. + * @param baseMethodGuard - The guard to narrow. + * @param patterns - The delta's patterns for this method. + * @returns The narrowed guard, asyncified for forwarding. + */ +const narrowMethodGuard = ( + methodName: string, + baseMethodGuard: MethodGuard, + patterns: (Pattern | undefined)[], +): MethodGuard => { + const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = + getMethodPayload(baseMethodGuard); + const optionals = optionalArgGuards ?? []; + const maxArity = argGuards.length + optionals.length; + + const beyondArity = patterns.findIndex( + (pattern, index) => index >= maxArity && pattern !== undefined, + ); + if (beyondArity !== -1 && restArgGuard === undefined) { + throw new Error( + `Cannot narrow argument ${beyondArity} of method "${methodName}": the base has arity ${maxArity} and no rest guard.`, + ); + } + + return buildMethodGuard( + M.callWhen( + ...argGuards.map((guard, index) => conjoin(guard, patterns[index])), + ), + optionals.map((guard, index) => + conjoin(guard, patterns[argGuards.length + index]), + ), + restArgGuard === undefined + ? undefined + : patterns.slice(maxArity).reduce(conjoin, restArgGuard), + returnGuard, + ); +}; + +/** + * Derive the interface guard of a narrowing of a base capability. + * + * Each delta pattern is conjoined onto the base's guard at the argument + * position it addresses. Arity, the required/optional/rest split, and return + * guards are inherited verbatim — a narrowed return guard could fail where the + * base succeeds, which would not be an unaltered forward. Methods the delta + * does not name are dropped. + * + * The result is constructed as a conjunction with the base's guard rather than + * checked against it, so it admits no call the base does not — the + * precondition that lets `join` disjoin deltas without deciding pattern + * subtyping. + * + * @param options - Options bag. + * @param options.name - The name for the derived interface guard. + * @param options.baseGuard - The interface guard being narrowed. + * @param options.delta - The patterns to conjoin, by method and position. + * @returns The derived interface guard. + */ +export const narrowInterfaceGuard = ({ + name, + baseGuard, + delta, +}: { + name: string; + baseGuard: InterfaceGuard; + delta: NarrowingDelta; +}): InterfaceGuard => { + const baseMethodGuards = getInterfaceMethodGuards(baseGuard); + const { defaultGuards } = getInterfaceGuardPayload(baseGuard) as unknown as { + defaultGuards?: 'passable' | 'raw'; + }; + + const narrowedMethodGuards: Record = {}; + for (const [methodName, patterns] of Object.entries(delta)) { + const baseMethodGuard = baseMethodGuards[methodName]; + if (baseMethodGuard === undefined) { + throw new Error( + defaultGuards === undefined + ? `Cannot narrow method "${methodName}": the base has no such method.` + : `Cannot narrow method "${methodName}": the base guards it by default, so there is no guard to conjoin onto.`, + ); + } + narrowedMethodGuards[methodName] = narrowMethodGuard( + methodName, + baseMethodGuard, + patterns, + ); + } + + return M.interface(name, narrowedMethodGuards); +}; diff --git a/packages/kernel-utils/src/narrowing.ts b/packages/kernel-utils/src/narrowing.ts index 7831f7aaf..d98533961 100644 --- a/packages/kernel-utils/src/narrowing.ts +++ b/packages/kernel-utils/src/narrowing.ts @@ -2,13 +2,7 @@ import type { Guarded, Methods } from '@endo/exo'; import { M } from '@endo/patterns'; import type { Pattern } from '@endo/patterns'; -/** - * A narrowing, addressed by method name and then by argument position. - * - * A method absent from the delta is dropped from the narrowing. An `undefined` - * slot leaves that argument position as the base has it. - */ -export type NarrowingDelta = Record; +import type { NarrowingDelta } from './narrow-interface-guard.ts'; /** * `base` is `object` rather than `Methods` because an `@endo/exo` carries no