From ca1c824efe9e70e23b9d141d4d5cf02846247729 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Fri, 19 Jan 2024 21:46:47 -0800 Subject: [PATCH] feat(liveslots): virtual exo meta-ops --- .../swingset-liveslots/src/vatDataTypes.d.ts | 41 ++++++- .../src/virtualObjectManager.js | 105 ++++++++++++++-- .../test/virtual-objects/test-cross-facet.js | 6 +- packages/vat-data/package.json | 4 +- .../test/test-amplify-virtual-class-kits.js | 89 ++++++++++++++ .../test-is-instance-virtual-class-kits.js | 115 ++++++++++++++++++ 6 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 packages/vat-data/test/test-amplify-virtual-class-kits.js create mode 100644 packages/vat-data/test/test-is-instance-virtual-class-kits.js diff --git a/packages/swingset-liveslots/src/vatDataTypes.d.ts b/packages/swingset-liveslots/src/vatDataTypes.d.ts index c417bef14fca..435a4ec0fe04 100644 --- a/packages/swingset-liveslots/src/vatDataTypes.d.ts +++ b/packages/swingset-liveslots/src/vatDataTypes.d.ts @@ -73,6 +73,41 @@ export type DefineKindOptions = { finish?: (context: C) => void; /** + * If provided, it describes the shape of all state records of instances + * of this kind. + */ + stateShape?: StateShape; + + /** + * If a `receiveAmplifier` function is provided, it will be called during + * definition of the exo class kit with an `Amplify` function. If called + * during the definition of a normal exo or exo class, it will throw, since + * only exo kits can be amplified. + * An `Amplify` function is a function that takes a facet instance of + * this class kit as an argument, in which case it will return the facets + * record, giving access to all the facet instances of the same cohort. + */ + receiveAmplifier?: ReceivePower>; + + /** + * If a `receiveInstanceTester` function is provided, it will be called + * during the definition of the exo class or exo class kit with an + * `IsInstance` function. The first argument of `IsInstance` + * is the value to be tested. When it may be a facet instance of an + * exo class kit, the optional second argument, if provided, is + * a `facetName`. In that case, the function tests only if the first + * argument is an instance of that facet of the associated exo class kit. + */ + receiveInstanceTester?: ReceivePower; + + // TODO properties above are identical to those in FarClassOptions. + // These are the only options that should be exposed by + // vat-data's public virtual/durable exo APIs. This DefineKindOptions + // should explicitly be a subtype, where the methods below are only for + // internal use, i.e., below the exo level. + + /** + * As a kind option, intended for internal use only. * Meaningful to `makeScalarBigMapStore` and its siblings. These maker * fuctions will make either virtual or durable stores, depending on * this flag. Defaults to off, making virtual but not durable collections. @@ -84,12 +119,6 @@ export type DefineKindOptions = { */ durable?: boolean; - /** - * If provided, it describes the shape of all state records of instances - * of this kind. - */ - stateShape?: StateShape; - /** * Intended for internal use only. * Should the raw methods receive their `context` argument as their first diff --git a/packages/swingset-liveslots/src/virtualObjectManager.js b/packages/swingset-liveslots/src/virtualObjectManager.js index 1b4728559a8e..12b0d7390e9f 100644 --- a/packages/swingset-liveslots/src/virtualObjectManager.js +++ b/packages/swingset-liveslots/src/virtualObjectManager.js @@ -28,8 +28,18 @@ import { * @typedef {import('@endo/exo/src/exo-tools.js').KitContextProvider } KitContextProvider */ -const { hasOwn, defineProperty, getOwnPropertyNames, entries, fromEntries } = - Object; +/** + * + */ + +const { + hasOwn, + defineProperty, + getOwnPropertyNames, + values, + entries, + fromEntries, +} = Object; const { ownKeys } = Reflect; // Turn on to give each exo instance its own toStringTag value which exposes @@ -679,6 +689,8 @@ export const makeVirtualObjectManager = ( const { finish = undefined, stateShape = undefined, + receiveAmplifier = undefined, + receiveInstanceTester = undefined, thisfulMethods = false, } = options; let { @@ -766,6 +778,11 @@ export const makeVirtualObjectManager = ( Fail`A stateShape must be a copyRecord: ${q(stateShape)}`; assertPattern(stateShape); + if (!multifaceted) { + receiveAmplifier === undefined || + Fail`Only facets of an exo class kit can be amplified ${q(tag)}`; + } + let facetNames; if (isDurable) { @@ -948,14 +965,20 @@ export const makeVirtualObjectManager = ( // and into method-invocation time (which is not). let proto; + /** @type {ClassContextProvider | undefined} */ + let contextProviderVar; + /** @type { Record | undefined } */ + let contextProviderKitVar; + if (multifaceted) { - /** @type { Record } */ - const contextProviderKit = fromEntries( + contextProviderKitVar = fromEntries( facetNames.map((name, index) => [ name, rep => { const vref = getSlotForVal(rep); - assert(vref !== undefined); + if (vref === undefined) { + return undefined; + } const { baseRef, facet } = parseVatSlot(vref); // Without this check, an attacker (with access to both @@ -966,7 +989,9 @@ export const makeVirtualObjectManager = ( // objects, but they could invoke all their equivalent methods, // by using e.g. // cohort1.facetA.foo.apply(cohort2.facetB, [...args]) - Number(facet) === index || Fail`illegal cross-facet access`; + if (Number(facet) !== index) { + return undefined; + } return harden(contextCache.get(baseRef)); }, @@ -975,21 +1000,22 @@ export const makeVirtualObjectManager = ( proto = defendPrototypeKit( tag, - harden(contextProviderKit), + harden(contextProviderKitVar), behavior, thisfulMethods, interfaceGuardKit, ); } else { - /** @type {ClassContextProvider} */ - const contextProvider = rep => { + contextProviderVar = rep => { const slot = getSlotForVal(rep); - assert(slot !== undefined); + if (slot === undefined) { + return undefined; + } return harden(contextCache.get(slot)); }; proto = defendPrototype( tag, - harden(contextProvider), + harden(contextProviderVar), behavior, thisfulMethods, interfaceGuard, @@ -997,6 +1023,10 @@ export const makeVirtualObjectManager = ( } harden(proto); + // All this to let typescript know that it won't vary during a closure + const contextProvider = contextProviderVar; + const contextProviderKit = contextProviderKitVar; + // this builds new Representatives, both when creating a new instance and // for reanimating an existing one when the old rep gets GCed @@ -1074,6 +1104,59 @@ export const makeVirtualObjectManager = ( return val; }; + if (receiveAmplifier) { + assert(contextProviderKit); + + // Amplify a facet to a cohort + const amplify = exoFacet => { + for (const cp of values(contextProviderKit)) { + const context = cp(exoFacet); + if (context !== undefined) { + return context.facets; + } + } + throw Fail`Must be a facet of ${q(tag)}: ${exoFacet}`; + }; + harden(amplify); + receiveAmplifier(amplify); + } + + if (receiveInstanceTester) { + if (multifaceted) { + assert(contextProviderKit); + + const isInstance = (exoFacet, facetName = undefined) => { + if (facetName === undefined) { + // Is exoFacet and instance of any facet of this class kit? + return values(contextProviderKit).some( + cp => cp(exoFacet) !== undefined, + ); + } + // Is this exoFacet an instance of this specific facet column + // of this class kit? + assert.typeof(facetName, 'string'); + const cp = contextProviderKit[facetName]; + cp !== undefined || + Fail`exo class kit ${q(tag)} has no facet named ${q(facetName)}`; + return cp(exoFacet) !== undefined; + }; + harden(isInstance); + receiveInstanceTester(isInstance); + } else { + assert(contextProvider); + // Is this exo an instance of this class? + const isInstance = (exo, facetName = undefined) => { + facetName === undefined || + Fail`facetName can only be used with an exo class kit: ${q( + tag, + )} has no facet ${q(facetName)}`; + return contextProvider(exo) !== undefined; + }; + harden(isInstance); + receiveInstanceTester(isInstance); + } + } + return makeNewInstance; }; diff --git a/packages/swingset-liveslots/test/virtual-objects/test-cross-facet.js b/packages/swingset-liveslots/test/virtual-objects/test-cross-facet.js index e943b46ae2c7..8ad096c637a6 100644 --- a/packages/swingset-liveslots/test/virtual-objects/test-cross-facet.js +++ b/packages/swingset-liveslots/test/virtual-objects/test-cross-facet.js @@ -33,10 +33,12 @@ test('forbid cross-facet prototype attack', t => { thing2.mutable.set(2); t.throws(() => attack1(thing1.mutable, thing2.immutable), { - message: /^illegal cross-facet access/, + message: + '"In \\"set\\" method of (thing mutable)" may only be applied to a valid instance: "[Alleged: thing immutable]"', }); t.throws(() => attack2(thing1.mutable, thing2.immutable), { - message: /^illegal cross-facet access/, + message: + '"In \\"set\\" method of (thing mutable)" may only be applied to a valid instance: "[Alleged: thing immutable]"', }); t.is(thing1.immutable.get(), 1); t.is(thing2.immutable.get(), 2); diff --git a/packages/vat-data/package.json b/packages/vat-data/package.json index a9ee8b3f1c26..2f725be68462 100644 --- a/packages/vat-data/package.json +++ b/packages/vat-data/package.json @@ -24,7 +24,9 @@ "@agoric/internal": "^0.3.2", "@agoric/store": "^0.9.2", "@agoric/swingset-liveslots": "^0.10.2", - "@agoric/vow": "^0.1.0" + "@agoric/vow": "^0.1.0", + "@endo/exo": "^1.1.0", + "@endo/patterns": "^1.1.0" }, "devDependencies": { "@endo/init": "^1.1.0", diff --git a/packages/vat-data/test/test-amplify-virtual-class-kits.js b/packages/vat-data/test/test-amplify-virtual-class-kits.js new file mode 100644 index 000000000000..2b47fb032d4b --- /dev/null +++ b/packages/vat-data/test/test-amplify-virtual-class-kits.js @@ -0,0 +1,89 @@ +// modeled on test-amplify-heap-class-kits.js +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { M } from '@endo/patterns'; +import { + defineVirtualExoClass, + defineVirtualExoClassKit, +} from '../src/exo-utils.js'; + +const UpCounterI = M.interface('UpCounter', { + incr: M.call().optional(M.gte(0)).returns(M.number()), +}); + +const DownCounterI = M.interface('DownCounter', { + decr: M.call().optional(M.gte(0)).returns(M.number()), +}); + +test('test amplify defineVirtualExoClass fails', t => { + t.throws( + () => + defineVirtualExoClass( + 'UpCounter', + UpCounterI, + /** @param {number} [x] */ + (x = 0) => ({ x }), + { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + { + receiveAmplifier(_) {}, + }, + ), + { + message: 'Only facets of an exo class kit can be amplified "UpCounter"', + }, + ); +}); + +test('test amplify defineVirtualExoClassKit', t => { + /** @type {import('@endo/exo/src/exo-makers.js').Amplify} */ + let amp; + const makeCounterKit = defineVirtualExoClassKit( + 'Counter', + { up: UpCounterI, down: DownCounterI }, + /** @param {number} [x] */ + (x = 0) => ({ x }), + { + up: { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + down: { + decr(y = 1) { + const { state } = this; + state.x -= y; + return state.x; + }, + }, + }, + { + receiveAmplifier(a) { + amp = a; + }, + }, + ); + // @ts-expect-error TS thinks it is used before assigned, which is a hazard + // TS is correct to bring to our attention, since there is not enough static + // into to infer otherwise. + assert(amp !== undefined); + + const counterKit = makeCounterKit(3); + const { up: upCounter, down: downCounter } = counterKit; + t.is(upCounter.incr(5), 8); + t.is(downCounter.decr(), 7); + + t.throws(() => amp(harden({})), { + message: 'Must be a facet of "Counter": {}', + }); + t.deepEqual(amp(upCounter), counterKit); + t.deepEqual(amp(downCounter), counterKit); +}); diff --git a/packages/vat-data/test/test-is-instance-virtual-class-kits.js b/packages/vat-data/test/test-is-instance-virtual-class-kits.js new file mode 100644 index 000000000000..b8cd2ed9cb9c --- /dev/null +++ b/packages/vat-data/test/test-is-instance-virtual-class-kits.js @@ -0,0 +1,115 @@ +// modeled on test-is-instance-heap-class-kits.js + +// eslint-disable-next-line import/order +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { M } from '@endo/patterns'; +import { + defineVirtualExoClass, + defineVirtualExoClassKit, +} from '../src/exo-utils.js'; + +const UpCounterI = M.interface('UpCounter', { + incr: M.call().optional(M.gte(0)).returns(M.number()), +}); + +const DownCounterI = M.interface('DownCounter', { + decr: M.call().optional(M.gte(0)).returns(M.number()), +}); + +test('test isInstance defineVirtualExoClass', t => { + // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error + // @ts-ignore IsInstance merged after last sync with endo + /** @type {import('@endo/exo/src/exo-makers.js').IsInstance} */ + let isInstance; + const makeUpCounter = defineVirtualExoClass( + 'UpCounter', + UpCounterI, + /** @param {number} x */ + (x = 0) => ({ x }), + { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + { + receiveInstanceTester(i) { + isInstance = i; + }, + }, + ); + // @ts-expect-error TS thinks it is used before assigned, which is a hazard + // TS is correct to bring to our attention, since there is not enough static + // into to infer otherwise. + assert(isInstance !== undefined); + + t.is(isInstance(harden({})), false); + t.throws(() => isInstance(harden({}), 'up'), { + message: + 'facetName can only be used with an exo class kit: "UpCounter" has no facet "up"', + }); + + const upCounter = makeUpCounter(3); + + t.is(isInstance(upCounter), true); + t.throws(() => isInstance(upCounter, 'up'), { + message: + 'facetName can only be used with an exo class kit: "UpCounter" has no facet "up"', + }); +}); + +test('test isInstance defineVirtualExoClassKit', t => { + // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error + // @ts-ignore IsInstance merged after last sync with endo + /** @type {import('@endo/exo/src/exo-makers.js').IsInstance} */ + let isInstance; + const makeCounterKit = defineVirtualExoClassKit( + 'Counter', + { up: UpCounterI, down: DownCounterI }, + /** @param {number} x */ + (x = 0) => ({ x }), + { + up: { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + down: { + decr(y = 1) { + const { state } = this; + state.x -= y; + return state.x; + }, + }, + }, + { + receiveInstanceTester(i) { + isInstance = i; + }, + }, + ); + // @ts-expect-error TS thinks it is used before assigned, which is a hazard + // TS is correct to bring to our attention, since there is not enough static + // into to infer otherwise. + assert(isInstance !== undefined); + + t.is(isInstance(harden({})), false); + t.is(isInstance(harden({}), 'up'), false); + t.throws(() => isInstance(harden({}), 'foo'), { + message: 'exo class kit "Counter" has no facet named "foo"', + }); + + const { up: upCounter } = makeCounterKit(3); + + t.is(isInstance(upCounter), true); + t.is(isInstance(upCounter, 'up'), true); + t.is(isInstance(upCounter, 'down'), false); + t.throws(() => isInstance(upCounter, 'foo'), { + message: 'exo class kit "Counter" has no facet named "foo"', + }); +});