From db3acfd522bd3c7c552c39bf40ebf9f021cb1090 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Fri, 7 Feb 2020 13:52:38 -0600 Subject: [PATCH] feat(eval): end-to-end metered evaluator --- package.json | 1 + packages/transform-metering/README.md | 34 ++++++ packages/transform-metering/package.json | 1 + packages/transform-metering/src/constants.js | 5 +- packages/transform-metering/src/endow.js | 115 ------------------ packages/transform-metering/src/evaluator.js | 39 ++++++ packages/transform-metering/src/index.js | 3 +- packages/transform-metering/src/meter.js | 14 ++- packages/transform-metering/src/transform.js | 23 +++- packages/transform-metering/src/with.js | 13 ++ .../transform-metering/test/test-endow.js | 50 -------- packages/transform-metering/test/test-tame.js | 52 ++++++++ .../transform-metering/test/test-transform.js | 10 +- .../{test-zzz-e2e.js => test-zzz-eval.js} | 39 +++--- 14 files changed, 201 insertions(+), 198 deletions(-) delete mode 100644 packages/transform-metering/src/endow.js create mode 100644 packages/transform-metering/src/evaluator.js create mode 100644 packages/transform-metering/src/with.js delete mode 100644 packages/transform-metering/test/test-endow.js create mode 100644 packages/transform-metering/test/test-tame.js rename packages/transform-metering/test/{test-zzz-e2e.js => test-zzz-eval.js} (52%) diff --git a/package.json b/package.json index 11d4be2b31c..977c29c1693 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "packages/acorn-eventual-send", "packages/eventual-send", "packages/transform-eventual-send", + "packages/tame-metering", "packages/transform-metering", "packages/default-evaluate-options", "packages/evaluate", diff --git a/packages/transform-metering/README.md b/packages/transform-metering/README.md index 35bf6b34f2a..b4ddb9a9da9 100644 --- a/packages/transform-metering/README.md +++ b/packages/transform-metering/README.md @@ -4,6 +4,40 @@ The purpose of this package is to provide a loose, but deterministic way to inte This technique is not airtight, but it is at least is a best approximation in the absence of an instrumented host platform. +## Quickstart + +```js +import SES from 'ses'; +import * as babelCore from '@babel/core'; +import { makeMeteredEvaluator } from '@agoric/transform-metering'; +import tameMetering from '@agoric/tame-metering'; + +// Override all the global objects with metered versions. +const setGlobalMeter = tameMetering(); + +const meteredEval = makeMeteredEvaluator({ + // Needed for enabling metering of the global builtins. + setGlobalMeter, + // Needed for source transforms that prevent runaways. + babelCore, + // ({ transforms }) => { evaluate(src, endowments = {}) { [eval function] } } + makeEvaluator: SES.makeSESRootRealm, // TODO: change to new SES/Compartment API +}); + +// Now use the returned meteredEval: it should not throw. +const { exhausted, exceptionBox, returned } = meteredEval(untrustedSource, /* endowments */); +if (exhausted) { + console.log('the meter was exhausted'); +} +if (exceptionBox) { + console.log('the source threw exception', exceptionBox[0]); +} else { + console.log('the source returned', returned); +} +``` + +# Implementation Details + ## Meter types There are three types of meters: diff --git a/packages/transform-metering/package.json b/packages/transform-metering/package.json index 79974a41468..ce869f10a34 100644 --- a/packages/transform-metering/package.json +++ b/packages/transform-metering/package.json @@ -24,6 +24,7 @@ "dependencies": { "@agoric/harden": "^0.0.4", "@agoric/nat": "^2.0.1", + "@agoric/tame-metering": "^1.0.0", "re2": "^1.10.5" }, "keywords": [], diff --git a/packages/transform-metering/src/constants.js b/packages/transform-metering/src/constants.js index 648aa8e17c9..eec8e5c13e6 100644 --- a/packages/transform-metering/src/constants.js +++ b/packages/transform-metering/src/constants.js @@ -1,8 +1,5 @@ // These are the meter members of the meterId. -export const METER_ALLOCATE = 'a'; -export const METER_COMPUTE = 'c'; -export const METER_ENTER = 'e'; -export const METER_LEAVE = 'l'; +export * from '@agoric/tame-metering/src/constants'; export const METER_COMBINED = '*'; export const DEFAULT_METER_ID = '$h\u200d_meter'; diff --git a/packages/transform-metering/src/endow.js b/packages/transform-metering/src/endow.js deleted file mode 100644 index 998a9596377..00000000000 --- a/packages/transform-metering/src/endow.js +++ /dev/null @@ -1,115 +0,0 @@ -import harden from '@agoric/harden'; -import * as c from './constants'; - -// We'd like to import this, but RE2 is cjs -const RE2 = require('re2'); - -const { - create, - defineProperties, - entries, - fromEntries, - getOwnPropertyDescriptors, - getPrototypeOf, -} = Object; - -export function makeMeteringEndowments( - meter, - globalsToShadow, - endowments = {}, - overrideMeterId = c.DEFAULT_METER_ID, -) { - const wrapped = new WeakMap(); - wrapped.set(meter, meter); - const meterId = overrideMeterId; - - const wrapDescriptor = desc => - // eslint-disable-next-line no-use-before-define - fromEntries(entries(desc).map(([k, v]) => [k, wrap(v)])); - - const shadowedRegexp = globalsToShadow.RegExp; - function wrap(target) { - if (shadowedRegexp !== undefined && target === shadowedRegexp) { - // Replace the RegExp object with RE2. - target = RE2; - } - - if (Object(target) !== target) { - return target; - } - - let wrapper = wrapped.get(target); - if (wrapper) { - return wrapper; - } - - if (typeof target === 'function') { - // Meter the call to the function/constructor. - wrapper = function meterFunction(...args) { - try { - meter[c.METER_ENTER](); - const newTarget = new.target; - let ret; - if (newTarget) { - ret = Reflect.construct(target, args, newTarget); - } else { - ret = Reflect.apply(target, this, args); - } - ret = meter[c.METER_ALLOCATE](ret); - // We are only scared of primitives that return functions. - // The other ones will be caught by the wrapped prototypes - // or instrumented user code. - if (typeof ret === 'function') { - return wrap(ret); - } - return ret; - } catch (e) { - throw meter[c.METER_ALLOCATE](e); - } finally { - meter[c.METER_LEAVE](); - } - }; - } else { - wrapper = create(getPrototypeOf(target)); - } - - // We have a wrapper identity, so prevent recursion. - wrapped.set(target, wrapper); - wrapped.set(wrapper, wrapper); - - // Assign the wrapped descriptors to the wrapper. - const descs = fromEntries( - entries(getOwnPropertyDescriptors(target)).map(([k, v]) => [ - k, - wrapDescriptor(v), - ]), - ); - defineProperties(wrapper, descs); - return wrapper; - } - - // Shadow the wrapped globals with the wrapped endowments. - const shadowDescs = create(null); - entries(getOwnPropertyDescriptors(globalsToShadow)).forEach(([p, desc]) => { - shadowDescs[p] = wrapDescriptor(desc); - }); - - entries(getOwnPropertyDescriptors(endowments)).forEach(([p, desc]) => { - // We wrap the endowment descriptors, too. - // If it is desirable to include a non-wrapped endowment, then add it to - // the returned shadow object later. - shadowDescs[p] = wrapDescriptor(desc); - }); - - // Set the meterId to be the meter. - shadowDescs[meterId] = { - configurable: false, - enumerable: false, - writable: false, - value: harden(meter), - }; - - // Package up these endowments as an object. - const e = create(null, shadowDescs); - return e; -} diff --git a/packages/transform-metering/src/evaluator.js b/packages/transform-metering/src/evaluator.js new file mode 100644 index 00000000000..31518964635 --- /dev/null +++ b/packages/transform-metering/src/evaluator.js @@ -0,0 +1,39 @@ +import { makeWithMeter } from './with'; +import { makeMeterAndResetters } from './meter'; +import { makeMeteringTransformer } from './transform'; + +export function makeMeteredEvaluator({ + setGlobalMeter, + makeEvaluator, + babelCore, + maxima, +}) { + const [meter, reset] = makeMeterAndResetters(maxima); + const { meterId, meteringTransform } = makeMeteringTransformer(babelCore); + const transforms = [meteringTransform]; + const { withMeter } = makeWithMeter(setGlobalMeter, meter); + + const ev = makeEvaluator({ transforms }); + + return (src, endowments = {}) => { + // Reset all meters to their defaults. + Object.values(reset).forEach(r => r()); + endowments[meterId] = meter; + let exhausted = true; + let returned; + let exceptionBox = false; + try { + // Evaluate the source with the meter. + returned = withMeter(() => ev.evaluate(src, { [meterId]: meter })); + exhausted = false; + } catch (e) { + exceptionBox = [e]; + exhausted = reset.isExhausted(); + } + return { + exhausted, + returned, + exceptionBox, + }; + }; +} diff --git a/packages/transform-metering/src/index.js b/packages/transform-metering/src/index.js index e97b22b3a91..ea041fdda72 100644 --- a/packages/transform-metering/src/index.js +++ b/packages/transform-metering/src/index.js @@ -1,3 +1,4 @@ +export { makeMeteredEvaluator } from './evaluator'; export { makeMeter, makeMeterAndResetters } from './meter'; -export { makeMeteringEndowments } from './endow'; export { makeMeteringTransformer } from './transform'; +export { makeWithMeter } from './with'; diff --git a/packages/transform-metering/src/meter.js b/packages/transform-metering/src/meter.js index aff2a64d910..f2f011bbb93 100644 --- a/packages/transform-metering/src/meter.js +++ b/packages/transform-metering/src/meter.js @@ -5,6 +5,8 @@ import * as c from './constants'; const { isArray } = Array; const { getOwnPropertyDescriptors } = Object; +const { ceil } = Math; +const ObjectConstructor = Object; // eslint-disable-next-line no-bitwise const bigIntWord = typeof BigInt !== 'undefined' && BigInt(1 << 32); @@ -64,7 +66,7 @@ export function makeAllocateMeter(maybeAbort, meter, allocateCounter = null) { meter[c.METER_ENTER](); maybeAbort(); let cost = 1; - if (value && Object(value) === value) { + if (value && ObjectConstructor(value) === value) { // Either an array or an object with properties. if (isArray(value)) { // The size of the array. This property cannot be overridden. @@ -83,7 +85,7 @@ export function makeAllocateMeter(maybeAbort, meter, allocateCounter = null) { switch (t) { case 'string': // The size of the string, in approximate words. - cost += Math.ceil(value.length / 4); + cost += ceil(value.length / 4); break; case 'bigint': { // Compute the number of words in the bigint. @@ -183,6 +185,14 @@ export function makeMeterAndResetters(maxima = {}) { } }; const resetters = { + isExhausted() { + try { + maybeAbort(); + return undefined; + } catch (e) { + return e; + } + }, allocate: makeResetter(allocateCounter), stack: makeResetter(stackCounter), compute: makeResetter(computeCounter), diff --git a/packages/transform-metering/src/transform.js b/packages/transform-metering/src/transform.js index 1e9a5697ade..9b19c6b4424 100644 --- a/packages/transform-metering/src/transform.js +++ b/packages/transform-metering/src/transform.js @@ -1,18 +1,26 @@ +import harden from '@agoric/harden'; + import * as c from './constants'; +// We'd like to import this, but RE2 is cjs +const RE2 = require('re2'); + const METER_GENERATED = Symbol('meter-generated'); export function makeMeteringTransformer( babelCore, - overrideParser = undefined, - overrideMeterId = c.DEFAULT_METER_ID, - overrideRegexpIdPrefix = c.DEFAULT_REGEXP_ID_PREFIX, + { + overrideParser = undefined, + overrideRegExp = harden(RE2), + overrideMeterId = c.DEFAULT_METER_ID, + overrideRegExpIdPrefix = c.DEFAULT_REGEXP_ID_PREFIX, + } = {}, ) { const parser = overrideParser ? overrideParser.parse || overrideParser : babelCore.parseSync; const meterId = overrideMeterId; - const regexpIdPrefix = overrideRegexpIdPrefix; + const regexpIdPrefix = overrideRegExpIdPrefix; let regexpNumber = 0; const meteringPlugin = regexpList => ({ types: t }) => { @@ -167,10 +175,17 @@ const ${reid}=RegExp(${JSON.stringify(pattern)},${JSON.stringify(flags)});`); ? `(function(){${reSource}return ${maybeSource}})()` : `${reSource}${maybeSource}`; + if (overrideRegExp) { + // By default, override with RE2, which protects against + // catastrophic backtracking. + endowments.RegExp = overrideRegExp; + } + // console.log('metered source:', actualSource); return { ...ss, ast, + endowments, src: actualSource, }; }, diff --git a/packages/transform-metering/src/with.js b/packages/transform-metering/src/with.js new file mode 100644 index 00000000000..57dbe4cb665 --- /dev/null +++ b/packages/transform-metering/src/with.js @@ -0,0 +1,13 @@ +export function makeWithMeter(setGlobalMeter, defaultMeter = null) { + const withMeter = (thunk, newMeter = defaultMeter) => { + let oldMeter; + try { + oldMeter = setGlobalMeter(newMeter); + return thunk(); + } finally { + setGlobalMeter(oldMeter); + } + }; + const withoutMeter = thunk => withMeter(thunk, null); + return { withMeter, withoutMeter }; +} diff --git a/packages/transform-metering/test/test-endow.js b/packages/transform-metering/test/test-endow.js deleted file mode 100644 index 70086eba0ab..00000000000 --- a/packages/transform-metering/test/test-endow.js +++ /dev/null @@ -1,50 +0,0 @@ -/* global globalThis */ -/* eslint-disable no-await-in-loop */ -import test from 'tape-promise/tape'; - -import { makeMeterAndResetters, makeMeteringEndowments } from '../src/index'; - -test('meter running', async t => { - try { - const [meter, resetters] = makeMeterAndResetters({ maxCombined: 10 }); - const e = makeMeteringEndowments(meter, globalThis); - t.throws(() => new e.Array(10), RangeError, 'new Array exhausted'); - - resetters.combined(20); - const a = new e.Array(10); - t.throws( - () => a.map(e.Object.create), - RangeError, - 'map to Object create exhausted', - ); - - resetters.combined(50); - const fthis = {}; - // eslint-disable-next-line no-new-func - const f = Function('return this'); - const fb = e.Function.prototype.bind.call(f, fthis); - t.equals(fb(), fthis, 'function is bound'); - t.throws( - () => { - for (let i = 0; i < 10; i += 1) { - fb(); - } - }, - RangeError, - 'bound function exhausted', - ); - - resetters.combined(50); - const re = e.RegExp('^ab*c'); - t.equals(re.test('abbbc'), true, 'regexp test works'); - t.equals(re.test('aac'), false, 'regexp test fails'); - t.equals(!'aac'.match(re), true, 'string match works'); - t.equals(!!'abbbc'.match(re), true, 'string match fails'); - t.throws(() => e.RegExp('(foo)\\1'), SyntaxError, 'backreferences throw'); - t.throws(() => e.RegExp('abc(?=def)'), SyntaxError, 'lookahead throws'); - } catch (e) { - t.isNot(e, e, 'unexpected exception'); - } finally { - t.end(); - } -}); diff --git a/packages/transform-metering/test/test-tame.js b/packages/transform-metering/test/test-tame.js new file mode 100644 index 00000000000..ffaff672d5d --- /dev/null +++ b/packages/transform-metering/test/test-tame.js @@ -0,0 +1,52 @@ +/* eslint-disable no-await-in-loop */ +import setGlobalMeter from '@agoric/tame-metering/src/install-global-metering'; + +// eslint-disable-next-line import/order +import test from 'tape-promise/tape'; + +import { makeMeterAndResetters, makeWithMeter } from '../src/index'; + +test('meter running', async t => { + try { + const [meter, resetters] = makeMeterAndResetters({ maxCombined: 10 }); + const { withMeter, withoutMeter } = makeWithMeter(setGlobalMeter, meter); + const withMeterFn = (thunk, newMeter = meter) => () => + withMeter(thunk, newMeter); + + t.equal(new [].constructor(40).length, 40, `new [].constructor works`); + + t.equal( + withoutMeter(() => true), + true, + `withoutMeter works`, + ); + t.equal( + withMeter(() => true), + true, + `withMeter works`, + ); + + resetters.combined(10); + t.throws( + withMeterFn(() => new Array(10)), + RangeError, + 'new Array exhausted', + ); + + resetters.combined(20); + withMeter(() => { + const a = new Array(10); + withoutMeter(() => + t.throws( + withMeterFn(() => a.map(Object.create)), + RangeError, + 'map to Object create exhausted', + ), + ); + }); + } catch (e) { + t.isNot(e, e, 'unexpected exception'); + } finally { + t.end(); + } +}); diff --git a/packages/transform-metering/test/test-transform.js b/packages/transform-metering/test/test-transform.js index 4046144e7e0..6cf7112087f 100644 --- a/packages/transform-metering/test/test-transform.js +++ b/packages/transform-metering/test/test-transform.js @@ -8,12 +8,10 @@ import * as c from '../src/constants'; test('meter transform', async t => { try { - const { meterId, meteringTransform } = makeMeteringTransformer( - babelCore, - undefined, - '$m', - '$re_', - ); + const { meterId, meteringTransform } = makeMeteringTransformer(babelCore, { + overrideMeterId: '$m', + overrideRegExpIdPrefix: '$re_', + }); const rewrite = (source, testName) => { let cMeter; const ss = meteringTransform.rewrite({ diff --git a/packages/transform-metering/test/test-zzz-e2e.js b/packages/transform-metering/test/test-zzz-eval.js similarity index 52% rename from packages/transform-metering/test/test-zzz-e2e.js rename to packages/transform-metering/test/test-zzz-eval.js index f0067cdd8e0..3ac39e47f61 100644 --- a/packages/transform-metering/test/test-zzz-e2e.js +++ b/packages/transform-metering/test/test-zzz-eval.js @@ -1,27 +1,32 @@ -/* global globalThis */ +// eslint-disable-next-line import/order +import setGlobalMeter from '@agoric/tame-metering/src/install-global-metering'; + /* eslint-disable no-await-in-loop */ import test from 'tape-promise/tape'; import * as babelCore from '@babel/core'; import SES from 'ses'; -import { - makeMeterAndResetters, - makeMeteringEndowments, - makeMeteringTransformer, -} from '../src/index'; +import { makeMeteredEvaluator } from '../src/index'; -test('metering end-to-end', async t => { +test('metering evaluator', async t => { try { - const [meter, reset] = makeMeterAndResetters(); - const { meterId, meteringTransform } = makeMeteringTransformer(babelCore); - const endowments = makeMeteringEndowments(meter, globalThis, {}, meterId); - const transforms = [meteringTransform]; - - const s = SES.makeSESRootRealm({ transforms }); + const meteredEval = makeMeteredEvaluator({ + setGlobalMeter, + babelCore, + makeEvaluator: SES.makeSESRootRealm, + }); + // Destructure the output of the meteredEval. + let exhaustedTimes = 0; const myEval = src => { - Object.values(reset).forEach(r => r()); - return s.evaluate(src, endowments); + const { exhausted, exceptionBox, returned } = meteredEval(src); + if (exhausted) { + exhaustedTimes += 1; + } + if (exceptionBox) { + throw exceptionBox[0]; + } + return returned; }; const src1 = `123; 456;`; @@ -57,7 +62,9 @@ while (true) {} const src5 = `\ new Array(1e6).map(Object.create) `; - t.throws(() => myEval(src5), RangeError, 'long map fails'); + t.throws(() => myEval(src5), /Allocate meter exceeded/, 'long map fails'); + + t.equals(exhaustedTimes, 3, `meter was exhausted as expected`); } catch (e) { t.isNot(e, e, 'unexpected exception'); } finally {