├── .travis.yml ├── index.js ├── lib ├── sandboxify-dat-archive.js ├── verifier.js ├── vm-factory.js ├── rpc-server.js ├── call-log.js └── vm.js ├── docs ├── vm-api.md └── api.md ├── package.json ├── test ├── vm-factory.js ├── verifier.js ├── rpc.js └── vm.js └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | VM: require('./lib/vm'), 3 | VMFactory: require('./lib/vm-factory'), 4 | CallLog: require('./lib/call-log'), 5 | DatArchive: require('node-dat-archive'), 6 | RPCServer: require('./lib/rpc-server'), 7 | RPCClient: require('nodevms-client'), 8 | Verifier: require('./lib/verifier') 9 | } -------------------------------------------------------------------------------- /lib/sandboxify-dat-archive.js: -------------------------------------------------------------------------------- 1 | const WHITELIST = [ 2 | 'getInfo', 3 | 'stat', 4 | 'readFile', 5 | 'readdir', 6 | 'writeFile', 7 | 'mkdir', 8 | 'unlink', 9 | 'rmdir', 10 | 'history' 11 | ] 12 | 13 | module.exports = function (archive) { 14 | var wrapper = {} 15 | WHITELIST.forEach(k => { 16 | wrapper[k] = (...args) => archive[k](...args) 17 | }) 18 | return wrapper 19 | } 20 | -------------------------------------------------------------------------------- /docs/vm-api.md: -------------------------------------------------------------------------------- 1 | # VM API 2 | 3 | This is the API provided to VMs for their execution. 4 | 5 | ```js 6 | const fs = System.files 7 | ``` 8 | 9 | ## System.caller.id 10 | 11 | The id of the calling user. VM scripts should assume this value is authenticated (and trustworthy). 12 | 13 | ## System.files 14 | 15 | This is a subset of the [DatArchive](https://beakerbrowser.com/docs/apis/dat.html) API. It provides access to the VM's files archive. 16 | 17 | ```js 18 | await System.files.getInfo() 19 | await System.files.stat(path) 20 | await System.files.readFile(path, opts) 21 | await System.files.readdir(path, opts) 22 | await System.files.writeFile(path, data, opts) 23 | await System.files.mkdir(path) 24 | await System.files.unlink(path) 25 | await System.files.rmdir(path, opts) 26 | await System.files.history(opts) 27 | ``` 28 | 29 | ## System.vms 30 | 31 | This is a special API which is only available to VM factory scripts. 32 | 33 | ```js 34 | await System.vms.provisionVM({title, code}) 35 | await System.vms.shutdownVM(id) 36 | ``` -------------------------------------------------------------------------------- /lib/verifier.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const deepEqual = require('deep-eql') 3 | const dft = require('diff-file-tree') 4 | const debug = require('debug')('vms') 5 | 6 | exports.compareLogs = async function (logA, logB) { 7 | const [entriesA, entriesB] = await Promise.all([logA.list(), logB.list()]) 8 | assert(entriesA.length === entriesB.length, 'Inequal number of call-log entries') 9 | assert(initEntriesEqual(entriesA, entriesB), 'Init entries do not match') 10 | for (let i = 1; i < entriesA.length; i++) { 11 | let entryA = entriesA[i] 12 | let entryB = entriesB[i] 13 | debug('checking entry', i) 14 | debug(entryA) 15 | debug(entryB) 16 | assert(deepEqual(entryA, entryB), `Mismatch on entry ${i}`) 17 | } 18 | } 19 | 20 | exports.compareArchives = async function (archiveA, archiveB) { 21 | var diffs = await dft.diff( 22 | {fs: archiveA._archive, path: '/'}, 23 | {fs: archiveB._archive, path: '/'}, 24 | {shallow: true, compareContent: true} 25 | ) 26 | // filter out the dat.json difference, which is expected 27 | debug('files diff', diffs) 28 | diffs = diffs.filter(d => d.path !== '/dat.json') 29 | assert(diffs.length === 0, 'Differences were found between the files') 30 | } 31 | 32 | function initEntriesEqual (a, b) { 33 | if (a.type !== b.type) return false 34 | if (a.code !== b.code) return false 35 | return true 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "libvms", 3 | "version": "2.0.2", 4 | "description": "API for running cryptographically auditable VMs.", 5 | "main": "index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "test": "NODE_ENV=test ava -s test/*.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/pfrazee/libvms.git" 15 | }, 16 | "keywords": [ 17 | "vms", 18 | "nodevms" 19 | ], 20 | "author": "Paul Frazee ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/pfrazee/libvms/issues" 24 | }, 25 | "homepage": "https://github.com/pfrazee/libvms#readme", 26 | "dependencies": { 27 | "concat-stream": "^1.6.0", 28 | "dat-swarm-defaults": "^1.0.0", 29 | "debug": "^2.6.8", 30 | "deep-eql": "^3.0.0", 31 | "diff-file-tree": "^2.1.1", 32 | "discovery-channel": "^5.4.5", 33 | "discovery-swarm": "^4.4.2", 34 | "hypercore": "^6.7.0", 35 | "mkdirp": "^0.5.1", 36 | "node-dat-archive": "^1.2.0", 37 | "nodevms-client": "^2.0.0", 38 | "random-access-file": "^1.8.1", 39 | "random-access-memory": "^2.4.0", 40 | "rpc-websockets": "github:pfrazee/rpc-websockets#ef053a26405bf4e2db03ce5334aff1ded27e99a1", 41 | "tempy": "^0.1.0", 42 | "uuid": "^3.1.0" 43 | }, 44 | "devDependencies": { 45 | "ava": "^0.21.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/vm-factory.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const path = require('path') 3 | const VM = require('./vm') 4 | 5 | // VM factory 6 | // = 7 | 8 | class VMFactory extends VM { 9 | constructor (code, {maxVMs} = {}) { 10 | super(code) 11 | this.maxVMs = maxVMs 12 | this.rpcServer = null 13 | this.VMs = {} 14 | this.numVMs = 0 15 | this.addAPI('vms', { 16 | provisionVM: opts => this.provisionVM(opts), 17 | shutdownVM: id => this.shutdownVM(id) 18 | }) 19 | } 20 | 21 | setRPCServer (s) { 22 | this.rpcServer = s 23 | } 24 | 25 | async provisionVM ({code, title}) { 26 | if (this.maxVMs) { 27 | assert(this.numVMs < this.maxVMs, 'This host is at maximum capacity') 28 | } 29 | assert(code && typeof code === 'string', 'Code is required') 30 | 31 | // initiate vm 32 | const vm = new VM(code) 33 | const dir = path.join(this.dir, vm.id) 34 | await vm.deploy({dir, title}) 35 | this.numVMs++ 36 | this.VMs[vm.id] = vm 37 | vm.on('close', () => { 38 | this.numVMs-- 39 | delete this.VMs[vm.id] 40 | }) 41 | 42 | // mount to the server 43 | this.rpcServer.mount('/' + vm.id, vm) 44 | 45 | return { 46 | id: vm.id, 47 | callLogUrl: vm.callLog.url, 48 | filesArchiveUrl: vm.filesArchive.url 49 | } 50 | } 51 | 52 | async shutdownVM (id) { 53 | // unmount from the server 54 | this.rpcServer.closeNamespace('/' + id) 55 | 56 | // close the VM 57 | await this.VMs[id].close() 58 | } 59 | 60 | getVM (id) { 61 | return this.VMs[id] 62 | } 63 | } 64 | 65 | module.exports = VMFactory 66 | -------------------------------------------------------------------------------- /test/vm-factory.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const tempy = require('tempy') 3 | const {VMFactory, RPCServer, RPCClient} = require('../') 4 | 5 | var vmFactory 6 | var rpcServer 7 | var factoryClient 8 | var vmInfo 9 | 10 | const VM_FACTORY_SCRIPT = ` 11 | exports.provisionVM = (args) => System.vms.provisionVM(args) 12 | exports.shutdownVM = (id) => System.vms.shutdownVM(id) 13 | ` 14 | 15 | const VM_SCRIPT = ` 16 | exports.hello = () => 'world' 17 | ` 18 | 19 | test('can deploy the VM factory & RPC server', async t => { 20 | // initiate vm 21 | vmFactory = new VMFactory(VM_FACTORY_SCRIPT) 22 | await vmFactory.deploy({dir: tempy.directory(), title: 'test'}) 23 | t.truthy(vmFactory.filesArchive, 'vmFactory files archive created') 24 | t.truthy(vmFactory.callLog, 'vmFactory call log created') 25 | 26 | // init rpc server, with the factory at root 27 | rpcServer = new RPCServer() 28 | rpcServer.mount('/foo', vmFactory) 29 | vmFactory.setRPCServer(rpcServer) 30 | await rpcServer.listen(5555) 31 | }) 32 | 33 | test('can connect and provision a VM', async t => { 34 | // connect 35 | factoryClient = new RPCClient() 36 | await factoryClient.connect('ws://localhost:5555/foo') 37 | t.deepEqual(factoryClient.backendInfo.methods, ['provisionVM', 'shutdownVM']) 38 | 39 | // provision 40 | vmInfo = await factoryClient.provisionVM({title: 'foo', code: VM_SCRIPT}) 41 | var vm = vmFactory.VMs[Object.keys(vmFactory.VMs)[0]] 42 | t.deepEqual(vmInfo, { 43 | id: vm.id, 44 | filesArchiveUrl: vm.filesArchive.url, 45 | callLogUrl: vm.callLog.url 46 | }) 47 | }) 48 | 49 | test('can connect to the provisioned VM and run calls', async t => { 50 | // connect 51 | const vmClient = new RPCClient() 52 | await vmClient.connect('ws://localhost:5555/' + vmInfo.id) 53 | t.deepEqual(vmClient.backendInfo.methods, ['hello']) 54 | 55 | // run calls 56 | t.deepEqual(await vmClient.hello(), 'world', 'can call to the provisioned vm') 57 | 58 | await vmClient.close() 59 | }) 60 | 61 | test('can close the client, server, and vm', async t => { 62 | await factoryClient.close() 63 | await rpcServer.close() 64 | await vmFactory.close() 65 | t.pass() 66 | }) -------------------------------------------------------------------------------- /test/verifier.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const tempy = require('tempy') 3 | const {VM, Verifier} = require('../') 4 | 5 | var vm 6 | var vm2 7 | 8 | const VM_SCRIPT = ` 9 | exports.func1 = (v = 0) => v + 1 10 | exports.func2 = async () => 'bar' 11 | exports.writeToFile = (v) => System.files.writeFile('/file', v) 12 | exports.random = () => System.test.random() 13 | ` 14 | 15 | test('can deploy the VM and run calls', async t => { 16 | // initiate vm 17 | vm = new VM(VM_SCRIPT) 18 | await vm.deploy({dir: tempy.directory(), title: 'test'}) 19 | t.truthy(vm.filesArchive, 'vm files archive created') 20 | t.truthy(vm.callLog, 'vm call log created') 21 | 22 | // run calls 23 | t.is(await vm.executeCall({methodName: 'func1'}), 1, 'execute call func1()') 24 | t.is(await vm.executeCall({methodName: 'func1', args: [5]}), 6, 'execute call func1(5)') 25 | t.is(await vm.executeCall({methodName: 'func1', args: [5], userId: 'bob'}), 6, 'execute call func1(5) with user') 26 | t.is(await vm.executeCall({methodName: 'func2'}), 'bar', 'execute call func2()') 27 | t.is(await vm.executeCall({methodName: 'func2', userId: 'bob'}), 'bar', 'execute call func2() with user') 28 | await vm.executeCall({methodName: 'writeToFile', args: ['foo']}) 29 | await vm.executeCall({methodName: 'writeToFile', args: ['bar']}) 30 | await vm.executeCall({methodName: 'writeToFile', args: ['baz']}) 31 | }) 32 | 33 | test('can rebuild a VM from a call log', async t => { 34 | // initiate new vm from the call log 35 | vm2 = await VM.fromCallLog(vm.callLog, {filesArchiveUrl: vm.filesArchive.url}) 36 | t.truthy(vm2.filesArchive, 'vm2 files archive created') 37 | t.truthy(vm2.callLog, 'vm2 call log created') 38 | 39 | // final output state is == 40 | await t.deepEqual(await vm2.filesArchive.readFile('/file'), 'baz', 'vm2 files are in expected state') 41 | }) 42 | 43 | test('matching logs and archives pass verification', async t => { 44 | // compare outputs (will throw on mismatch) 45 | await Verifier.compareLogs(vm.callLog, vm2.callLog) 46 | await Verifier.compareArchives(vm.filesArchive, vm2.filesArchive) 47 | t.pass() 48 | }) 49 | 50 | test('mismatching archives fail verification', async t => { 51 | // change the original 52 | await vm.filesArchive.writeFile('/test', 'fail!') 53 | 54 | try { 55 | // compare archives (will throw on mismatch) 56 | await Verifier.compareArchives(vm.filesArchive, vm2.filesArchive) 57 | t.fail('should have failed files-archive validation') 58 | } catch (e) { 59 | t.pass() 60 | } 61 | }) 62 | 63 | test('can close the VMs', async t => { 64 | await vm2.close() 65 | t.falsy(vm2.filesArchive) 66 | t.falsy(vm2.callLog) 67 | await vm.close() 68 | t.falsy(vm.filesArchive) 69 | t.falsy(vm.callLog) 70 | }) 71 | 72 | test('nondeterministic scripts will fail verification', async t => { 73 | // initiate vm 74 | var randomVM = new VM(VM_SCRIPT) 75 | await randomVM.deploy({dir: tempy.directory(), title: 'test'}) 76 | t.truthy(randomVM.filesArchive, 'randomVM files archive created') 77 | t.truthy(randomVM.callLog, 'randomVM call log created') 78 | 79 | // run calls 80 | await randomVM.executeCall({methodName: 'random'}) 81 | await randomVM.executeCall({methodName: 'random'}) 82 | await randomVM.executeCall({methodName: 'random'}) 83 | 84 | // replay 85 | var randomVM2 = await VM.fromCallLog(randomVM.callLog, {filesArchiveUrl: randomVM.filesArchive.url}) 86 | 87 | // compare logs (will throw on mismatch) 88 | await t.throws(Verifier.compareLogs(randomVM.callLog, randomVM2.callLog)) 89 | }) -------------------------------------------------------------------------------- /test/rpc.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const tempy = require('tempy') 3 | const {VM, RPCServer, RPCClient} = require('../') 4 | 5 | var vm 6 | var rpcServer 7 | var rpcClient 8 | 9 | const VM_SCRIPT = ` 10 | exports.func1 = (v = 0) => v + 1 11 | exports.func2 = async () => 'bar' 12 | exports.writeToFile = (v) => System.files.writeFile('/file', v) 13 | exports.writeCallerToFile = () => exports.writeToFile(System.caller.id) 14 | 15 | // this is used to prove that races dont occur 16 | // the wait *decreases* with each invocation 17 | // the script-wide lock stops that from changing the write order 18 | var waitTime = 250 19 | exports.waitThenWriteToFile = async (v) => { 20 | await new Promise(resolve => setTimeout(resolve, waitTime)) 21 | waitTime -= 50 22 | await System.files.writeFile('/file', ''+v) 23 | } 24 | 25 | exports.var = 'bar' 26 | ` 27 | 28 | test('can deploy the VM & RPC server', async t => { 29 | // initiate vm 30 | vm = new VM(VM_SCRIPT) 31 | await vm.deploy({dir: tempy.directory(), title: 'test'}) 32 | t.truthy(vm.filesArchive, 'vm files archive created') 33 | t.truthy(vm.callLog, 'vm call log created') 34 | 35 | // initiate RPC server 36 | rpcServer = new RPCServer() 37 | rpcServer.mount('/vm1', vm) 38 | await rpcServer.listen(5555) 39 | }) 40 | 41 | test('can connect and make calls', async t => { 42 | // connect 43 | rpcClient = new RPCClient() 44 | await rpcClient.connect('ws://localhost:5555/vm1') 45 | t.deepEqual(rpcClient.backendInfo.methods, ['func1', 'func2', 'writeToFile', 'writeCallerToFile', 'waitThenWriteToFile']) 46 | 47 | // make calls 48 | t.deepEqual(await rpcClient.func1(), 1, 'rpc client func1()') 49 | t.deepEqual(await rpcClient.func1(5), 6, 'rpc client func1(5)') 50 | t.deepEqual(await rpcClient.func2(), 'bar', 'rpc client func2()') 51 | t.deepEqual(await rpcClient.writeToFile('foo'), undefined, 'rpc client writeToFile("foo")') 52 | }) 53 | 54 | test('calls do not race', async t => { 55 | // make calls 56 | for (let i = 1; i <= 5; i++) { 57 | await rpcClient.waitThenWriteToFile(i) 58 | } 59 | 60 | // test final value 61 | t.deepEqual(await vm.filesArchive.readFile('/file'), '5', 'calls do not race') 62 | }) 63 | 64 | test('can handle calls from multiple clients', async t => { 65 | // add 2 more connections 66 | var rpcClient2 = new RPCClient() 67 | await rpcClient2.connect('ws://localhost:5555/vm1', {user: 'alice'}) 68 | var rpcClient3 = new RPCClient() 69 | await rpcClient3.connect('ws://localhost:5555/vm1', {user: 'bob'}) 70 | 71 | // make calls 72 | await rpcClient.writeCallerToFile() 73 | await rpcClient2.writeCallerToFile() 74 | await rpcClient3.writeCallerToFile() 75 | 76 | // test final value 77 | t.deepEqual(await vm.filesArchive.readFile('/file'), 'bob', 'multiple clients do not race') 78 | 79 | // close the extra connections 80 | await rpcClient2.close() 81 | await rpcClient3.close() 82 | }) 83 | 84 | test('init method is not exposed over RPC', async t => { 85 | // open a websocket 86 | const WebSocketClient = require('rpc-websockets').Client 87 | const wsClient = new WebSocketClient('ws://localhost:5555') 88 | 89 | // wait for the socket to open 90 | await new Promise((resolve, reject) => { 91 | wsClient.on('open', resolve) 92 | wsClient.on('error', reject) 93 | }) 94 | 95 | // try to call init 96 | try { 97 | await wsClient.call('init') 98 | t.fail('init() did not fail') 99 | } catch (e) { 100 | t.deepEqual(e, {code: -32601, message: 'Method not found'}) 101 | } 102 | 103 | wsClient.close() 104 | }) 105 | 106 | test('can close the client, server, and vm', async t => { 107 | await rpcClient.close() 108 | await rpcServer.close() 109 | await vm.close() 110 | t.pass() 111 | }) -------------------------------------------------------------------------------- /lib/rpc-server.js: -------------------------------------------------------------------------------- 1 | const WebSocketServer = require('rpc-websockets').Server 2 | const debug = require('debug')('vms') 3 | const DEFAULT_PORT = 5555 4 | const MAX_QUEUE_LENGTH = 1e3 5 | 6 | // methods that can not be called remotely 7 | const RPC_METHODS_BLACKLIST = ['init'] 8 | 9 | class RPCServer { 10 | constructor () { 11 | this.server = null 12 | this.mounts = {} 13 | } 14 | 15 | mount (path, vm) { 16 | // add the given vm to the server 17 | this.mounts[path] = new MountedVM(path, vm) 18 | if (this.server) { 19 | this.mounts[path].register(this) 20 | } 21 | } 22 | 23 | unmount (path) { 24 | if (this.mounts[path]) { 25 | this.mounts[path].unregister(this) 26 | delete this.mounts[path] 27 | } 28 | } 29 | 30 | async listen (port = DEFAULT_PORT) { 31 | if (this.server) { 32 | throw new Error('Already listening') 33 | } 34 | 35 | // start the websocket server 36 | this.server = new WebSocketServer({port}) 37 | await new Promise((resolve, reject) => { 38 | this.server.on('listening', resolve) 39 | this.server.on('error', reject) 40 | }) 41 | 42 | // mount any waiting vms 43 | for (let path in this.mounts) { 44 | this.mounts[path].register(this) 45 | } 46 | } 47 | 48 | close () { 49 | this.server.close() 50 | this.server = null 51 | } 52 | } 53 | 54 | class MountedVM { 55 | constructor (path, vm) { 56 | this.path = path 57 | this.vm = vm 58 | this.callQueue = [] // backlog of RPC requests 59 | this.activeCall = null // call currently being processed 60 | } 61 | 62 | register (rpcServer) { 63 | // register all exported commands 64 | let methods = [] 65 | for (let methodName in this.vm.exports) { 66 | let method = this.vm.exports[methodName] 67 | if (typeof method === 'function') { 68 | rpcServer.server.register( 69 | methodName, 70 | (args, meta) => this.queueRPCCall(methodName, args, meta), 71 | this.path 72 | ) 73 | methods.push(methodName) 74 | } 75 | } 76 | methods = methods.filter(m => RPC_METHODS_BLACKLIST.indexOf(m) === -1) 77 | 78 | // register standard methods 79 | rpcServer.server.register( 80 | 'handshake', 81 | () => { 82 | return { 83 | methods, 84 | callLogUrl: this.vm.callLog.url, 85 | filesArchiveUrl: this.vm.filesArchive.url 86 | } 87 | }, 88 | this.path 89 | ) 90 | } 91 | 92 | unregister (rpcServer) { 93 | if (rpcServer.server) { 94 | rpcServer.server.closeNamespace(this.path) 95 | } 96 | } 97 | 98 | queueRPCCall (methodName, args, meta) { 99 | debug('got call', methodName, args, meta) 100 | if (this.callQueue.length > MAX_QUEUE_LENGTH) { 101 | throw new Error('Too many active requests. Try again in a few minutes.') 102 | } 103 | 104 | if (RPC_METHODS_BLACKLIST.indexOf(methodName) !== -1) { 105 | throw new Error('RPC method not supported') 106 | } 107 | 108 | // add the call to the queue and then process the queue 109 | var promise = new Promise((resolve, reject) => { 110 | this.callQueue.push({ 111 | resolve, 112 | reject, 113 | methodName, 114 | args, 115 | userId: meta.user_id 116 | }) 117 | }) 118 | this.kickCallQueue() 119 | return promise 120 | } 121 | 122 | async kickCallQueue () { 123 | if (this.activeCall) { 124 | return // already handling a call 125 | } 126 | if (!this.callQueue.length) { 127 | return // no queued calls 128 | } 129 | // run the top call on the queue 130 | this.activeCall = this.callQueue.shift() 131 | debug('handling call', this.activeCall) 132 | try { 133 | this.activeCall.resolve(await this.vm.executeCall(this.activeCall)) 134 | } catch (e) { 135 | this.activeCall.reject(e) 136 | } 137 | this.activeCall = null 138 | // continue to the next call 139 | this.kickCallQueue() 140 | } 141 | } 142 | 143 | module.exports = RPCServer 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LibVMS (alpha, v2.0.0) [![Build Status](https://travis-ci.org/pfrazee/libvms.svg?branch=master)](https://travis-ci.org/pfrazee/libvms) 2 | 3 | An API for running cryptographically auditable VM services. Part of [NodeVMS](https://npm.im/nodevms). 4 | 5 | ## Overview 6 | 7 | LibVMS is a Javascript VM toolset built on NodeJS. Its goal is to auditably execute services on untrusted or semi-trusted hardware. 8 | 9 | To accomplish this, LibVMS uses [an append-only ledger](https://npm.im/hypercore) to maintain a call log. The call log records the VM script, all RPC calls, and all call results. The log is then distributed on the [Dat network](https://beakerbrowser.com/docs/inside-beaker/dat-files-protocol.html); it can not be forged, and it can not be altered after distribution (alterations are trivial to detect). 10 | 11 | For each VM, LibVMS provisions a [Dat files archive](https://npm.im/hyperdrive) to store state. The archive is distributed over the Dat network for clients to read. As with the call log, the files archive is backed by an append-only ledger. 12 | 13 | ### Auditing 14 | 15 | The security of LibVMS rests in the unforgeability of its ledgers, and the ability to fully replay the VM history. 16 | 17 | Any client can download the call log and files archive, instantiate their own copy of the VM, and replay the log to verify the results. If a replay is found to produce mismatched state, we can assume either A) the VM script has nondeterministic behaviors, or B) the host has tampered with the state of the VM. In either case, the VM is no longer trustworthy. 18 | 19 | ### Authentication 20 | 21 | LibVMS has a concept of users and user ids. In debug mode, the user ids are plain authenticated strings. In production mode, the user ids are authenticated public keys and all calls are signed. 22 | 23 | Currently, only debug mode authentication is implemented. 24 | 25 | ### VM environment 26 | 27 | LibVMS exposes a set of APIs to the VMs using the global `System` object. Currently, it is a fixed API ([see docs](./docs/vm-api.md)). 28 | 29 | ### Oracles 30 | 31 | "Oracles" are a portion of effectful blackbox code which is executed by the host environment. Their execution is wrapped and their results are cached to the call ledger so that they are *not* executed on replay. (Oracles require trust in the host environment to execute correctly.) 32 | 33 | Currently, oracles are not yet implemented. 34 | 35 | ## Docs 36 | 37 | - [API documentation](./docs/api.md) 38 | - [VM API documentation](./docs/vm-api.md) 39 | 40 | ## Examples 41 | 42 | ### Run a VM 43 | 44 | ```js 45 | const {VM, RPCServer} = require('libvms') 46 | 47 | // the script 48 | const scriptCode = ` 49 | exports.foo = () => 'bar' 50 | ` 51 | const dir = './bobs-vm-data' 52 | const title = 'Bobs VM' 53 | 54 | // initiate vm 55 | const vm = new VM(scriptCode) 56 | await vm.deploy({dir, title}) 57 | console.log('vm api exports:', Object.keys(vm.exports)) 58 | 59 | // init rpc server 60 | var rpcServer = new RPCServer() 61 | rpcServer.mount('/bobs-vm', vm) 62 | await rpcServer.listen(5555) 63 | console.log('Serving at localhost:5555') 64 | console.log('Files URL:', vm.filesArchive.url) 65 | console.log('Call log URL:', vm.callLog.url) 66 | ``` 67 | 68 | ### Connect to run commands 69 | 70 | ```js 71 | const {RPCClient} = require('libvms') 72 | 73 | // connect to the server 74 | const client = new RPCClient() 75 | await client.connect('ws://localhost:5555/bobs-vm') 76 | 77 | // run the command 78 | console.log(await client.foo()) // => 'bar' 79 | ``` 80 | 81 | ### Audit the VM state 82 | 83 | ```js 84 | const {RPCClient, CallLog, DatArchive, VM} = require('libvms') 85 | 86 | // connect to the server 87 | const client = new RPCClient() 88 | await client.connect('ws://localhost:5555/bobs-vm') 89 | 90 | // fetch the call log 91 | const callLog = await CallLog.fetch(client.backendInfo.callLogUrl) 92 | 93 | // fetch the dat archive 94 | const filesArchive = new DatArchive(client.backendInfo.filesArchiveUrl) 95 | await filesArchive.download('/') 96 | 97 | // replay the call log 98 | const vm = await VM.fromCallLog(callLog, client.backendInfo, {dir: opts.dir}) 99 | 100 | // compare outputs (will throw on mismatch) 101 | await Verifier.compareLogs(callLog, vm.callLog) 102 | await Verifier.compareArchives(filesArchive, vm.filesArchive) 103 | ``` -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This is the exported API for using LibVMS in a node project. 4 | 5 | ```js 6 | const { 7 | VM, 8 | VMFactory, 9 | CallLog, 10 | DatArchive, 11 | Verifier, 12 | RPCServer, 13 | RPCClient 14 | } = require('libvms') 15 | ``` 16 | 17 | ## VM 18 | 19 | ```js 20 | const {VM} = require('libvms') 21 | 22 | // step 1. instantiate vm with a script 23 | var vm = new VM(`exports.foo = () => 'bar'`) 24 | 25 | // step 2. start the files archive & call log, and eval the script 26 | await vm.deploy({ 27 | dir: './foo-vm-data', 28 | title: 'The Foo Backend' 29 | }) 30 | 31 | // step 3. start the RPC server 32 | const {RPCServer} = require('libvms') 33 | var server = new RPCServer() 34 | server.mount('/foo', vm) 35 | await server.listen({port: 5555}) 36 | 37 | // now serving! 38 | 39 | // attributes: 40 | vm.code // vm script contents 41 | vm.exports // the `module.exports` of the vm script 42 | vm.filesArchive // the vm's DatArchive 43 | vm.callLog // the vm's CallLog 44 | 45 | // events 46 | vm.on('ready') 47 | vm.on('close') 48 | 49 | // methods 50 | await vm.close() 51 | 52 | // alternative instantiation: replaying a call log 53 | var vm = VM.fromCallLog(callLog, assertions) 54 | // ^ assertions are values in the call log that need to be tested, currently: 55 | // - filesArchiveUrl: the url expected for the files archive 56 | ``` 57 | 58 | ## VMFactory 59 | 60 | The `VMFactory` is a subtype of the `VM`, designed to mount other VMs. 61 | 62 | ```js 63 | const {VMFactory} = require('libvms') 64 | 65 | // step 1. instantiate vmfactory 66 | var vmFactory = new VMFactory({maxVMs: 100}) 67 | 68 | // step 2. start the factory's files archive & call log 69 | await vmFactory.deploy({ 70 | dir: './vms', 71 | title: 'The Foo VM Host' 72 | }) 73 | 74 | // step 3. start the RPC server 75 | const {RPCServer} = require('libvms') 76 | var server = new RPCServer() 77 | server.mount('/', vmFactory) 78 | vmFactory.setRPCServer(rpcServer) 79 | await server.listen({port: 5555}) 80 | 81 | // now serving! 82 | 83 | // attributes: 84 | vmFactory.code // vm script contents 85 | vmFactory.exports // the `module.exports` of the vm script 86 | vmFactory.filesArchive // the vm's DatArchive 87 | vmFactory.callLog // the vm's CallLog 88 | 89 | // methods: 90 | await vmFactory.provisionVM({code, title}) 91 | await vmFactory.shutdownVM(id) 92 | await vmFactory.close() 93 | ``` 94 | 95 | ## CallLog 96 | 97 | ```js 98 | const {CallLog} = require('libvms') 99 | 100 | // create, open, or fetch the log 101 | var callLog = CallLog.create(dir, code, filesArchiveUrl) 102 | var callLog = CallLog.open(dir) 103 | var callLog = CallLog.fetch(callLogUrl, dir) // if `dir` is falsy, will use memory 104 | 105 | // methods/attrs: 106 | callLog.length // how many entries in the log 107 | await callLog.list({start, end}) // list the entries. start/end optional 108 | await callLog.get(seq, { // get the entry at `seq` 109 | wait: true, // wait for index to be downloaded 110 | timeout: 0, // wait at max some milliseconds (0 means no timeout) 111 | valueEncoding: 'json' | 'utf-8' | 'binary' // defaults to the feed's valueEncoding 112 | }) 113 | 114 | // appends (used internally): 115 | await callLog.append(obj) 116 | await callLog.appendInit({code, filesArchiveUrl}) 117 | await callLog.appendCall({userId, methodName, args, res, err, filesVersion}) 118 | ``` 119 | 120 | ## DatArchive 121 | 122 | See [node-dat-archive](https://npm.im/node-dat-archive) 123 | 124 | ## Verifier 125 | 126 | ```js 127 | const {Verifier} = require('libvms') 128 | 129 | await Verifier.compareLogs(callLogA, callLogB) 130 | await Verifier.compareArchives(archiveA, archiveB) 131 | ``` 132 | 133 | ## RPCServer 134 | 135 | ```js 136 | const {RPCServer} = require('libvms') 137 | 138 | var server = new RPCServer() 139 | server.mount(path, vm) 140 | server.unmount(path) 141 | await server.listen({port:}) 142 | server.close() 143 | ``` 144 | 145 | ## RPCClient 146 | 147 | ```js 148 | const {RPCClient} = require('libvms') 149 | 150 | const client = new RPCClient() 151 | await client.connect(url, {user:}) // 'user' is optional 152 | 153 | client.url // => string 154 | client.backendInfo.methods // => Array of strings, the method names 155 | client.backendInfo.callLogUrl // => url of the vm's call log 156 | client.backendInfo.filesArchiveUrl // => url of the vm's files archive 157 | 158 | // all methods exported by the vm will be attached to `client` 159 | await client.foo() // => 'bar' 160 | client.close() 161 | ``` -------------------------------------------------------------------------------- /lib/call-log.js: -------------------------------------------------------------------------------- 1 | const hypercore = require('hypercore') 2 | const concat = require('concat-stream') 3 | const raf = require('random-access-file') 4 | const ram = require('random-access-memory') 5 | const swarmDefaults = require('dat-swarm-defaults') 6 | const disc = require('discovery-swarm') 7 | const debug = require('debug')('vms') 8 | 9 | const DEFAULT_PORT = 3282 10 | 11 | class CallLog { 12 | constructor (_hc) { 13 | this.hc = _hc 14 | this.url = 'dat://' + _hc.key.toString('hex') 15 | } 16 | 17 | async close () { 18 | await new Promise((resolve, reject) => { 19 | this.hc.close(err => { 20 | if (err) reject(err) 21 | else resolve() 22 | }) 23 | }) 24 | } 25 | 26 | get length () { 27 | return this.hc.length 28 | } 29 | 30 | async append (obj) { 31 | debug('call log appending', obj) 32 | await new Promise((resolve, reject) => { 33 | this.hc.append(obj, err => { 34 | if (err) reject(err) 35 | else resolve() 36 | }) 37 | }) 38 | } 39 | 40 | appendInit ({code, filesArchiveUrl}) { 41 | return this.append({ 42 | type: 'init', 43 | filesArchiveUrl, 44 | code 45 | }) 46 | } 47 | 48 | appendCall ({userId, methodName, args, res, err, filesVersion}) { 49 | return this.append({ 50 | type: 'call', 51 | call: { 52 | userId, 53 | methodName, 54 | args 55 | }, 56 | result: { 57 | res, 58 | err: err ? err.name || err.message : undefined, 59 | filesVersion 60 | } 61 | }) 62 | } 63 | 64 | get (seq, opts) { 65 | return new Promise((resolve, reject) => { 66 | this.hc.get(seq, opts, (err, res) => { 67 | if (err) reject(err) 68 | else resolve(res) 69 | }) 70 | }) 71 | } 72 | 73 | list ({start, end} = {}) { 74 | return new Promise((resolve, reject) => { 75 | const rs = this.hc.createReadStream({start, end}) 76 | rs.on('error', reject) 77 | rs.pipe(concat({encoding: 'object'}, resolve)) 78 | }) 79 | } 80 | } 81 | 82 | exports.create = async function (dir, code, filesArchiveUrl) { 83 | debug('creating new call log at', dir) 84 | var hc = hypercore(storage(dir), {valueEncoding: 'json'}) 85 | await new Promise((resolve, reject) => { 86 | hc.on('ready', resolve) 87 | hc.on('error', reject) 88 | }) 89 | joinSwarm(hc) 90 | var log = new CallLog(hc) 91 | await log.appendInit({code, filesArchiveUrl}) 92 | return log 93 | } 94 | 95 | exports.open = async function (dir) { 96 | debug('opening existing call log at', dir) 97 | var hc = hypercore(storage(dir), {valueEncoding: 'json'}) 98 | await new Promise((resolve, reject) => { 99 | hc.on('ready', resolve) 100 | hc.on('error', reject) 101 | }) 102 | joinSwarm(hc) 103 | return new CallLog(hc) 104 | } 105 | 106 | exports.fetch = async function (callLogUrl, dir) { 107 | var key = datUrlToKey(callLogUrl) 108 | debug('fetching existing call log, storing to', dir || 'memory') 109 | debug('key is', key) 110 | var hc = hypercore(dir ? storage(dir) : memory, key, {valueEncoding: 'json'}) 111 | await new Promise((resolve, reject) => { 112 | hc.on('ready', resolve) 113 | hc.on('error', reject) 114 | }) 115 | joinSwarm(hc) 116 | await new Promise((resolve, reject) => { 117 | hc.on('sync', resolve) 118 | hc.on('error', reject) 119 | }) 120 | return new CallLog(hc) 121 | } 122 | 123 | function joinSwarm (hc) { 124 | var swarm = disc(swarmDefaults({ 125 | hash: false, 126 | stream: peer => { 127 | var stream = hc.replicate({ 128 | upload: true, 129 | download: true, 130 | live: true 131 | }) 132 | // stream.on('close', function () { 133 | // debug('replication stream closed') 134 | // }) 135 | // stream.on('error', function (err) { 136 | // debug('replication error:', err.message) 137 | // }) 138 | // stream.on('end', function () { 139 | // debug('replication stream ended') 140 | // }) 141 | return stream 142 | } 143 | })) 144 | swarm.once('error', function () { 145 | swarm.listen(0) 146 | }) 147 | swarm.listen(DEFAULT_PORT) // this is probably colliding with the files archive 148 | swarm.join(hc.discoveryKey, { announce: true }) 149 | } 150 | 151 | function storage (directory) { 152 | return filename => { 153 | return raf('call-log.' + filename, {directory}) 154 | } 155 | } 156 | 157 | function memory (filename) { 158 | return ram() 159 | } 160 | 161 | function datUrlToKey (url) { 162 | var match = /[0-9a-f]{64}/i.exec(url) 163 | return match ? match[0] : null 164 | } 165 | -------------------------------------------------------------------------------- /test/vm.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const tempy = require('tempy') 3 | const {VM} = require('../') 4 | 5 | var vm 6 | 7 | const VM_SCRIPT = ` 8 | exports.func1 = (v = 0) => v + 1 9 | exports.func2 = async () => 'bar' 10 | exports.writeToFile = (v) => System.files.writeFile('/file', v) 11 | exports.var = 'bar' 12 | ` 13 | 14 | test('can deploy the VM', async t => { 15 | // initiate vm 16 | vm = new VM(VM_SCRIPT) 17 | await vm.deploy({dir: tempy.directory(), title: 'test'}) 18 | t.truthy(vm.filesArchive, 'vm files archive created') 19 | t.truthy(vm.callLog, 'vm call log created') 20 | }) 21 | 22 | test('have access to exports', async t => { 23 | t.deepEqual(Object.keys(vm.exports), ['func1', 'func2', 'writeToFile', 'var']) 24 | }) 25 | 26 | test('init message is recorded to the call log', async t => { 27 | t.deepEqual(await vm.callLog.get(0), { 28 | type: 'init', 29 | code: VM_SCRIPT, 30 | filesArchiveUrl: vm.filesArchive.url 31 | }) 32 | }) 33 | 34 | test('calls are logged in the call log', async t => { 35 | // run calls 36 | t.is(await vm.executeCall({methodName: 'func1'}), 1, 'execute call func1()') 37 | t.is(await vm.executeCall({methodName: 'func1', args: [5]}), 6, 'execute call func1(5)') 38 | t.is(await vm.executeCall({methodName: 'func1', args: [5], userId: 'bob'}), 6, 'execute call func1(5) with user') 39 | t.is(await vm.executeCall({methodName: 'func2'}), 'bar', 'execute call func2()') 40 | t.is(await vm.executeCall({methodName: 'func2', userId: 'bob'}), 'bar', 'execute call func2() with user') 41 | 42 | // check call log 43 | t.deepEqual(await vm.callLog.get(1), { 44 | type: 'call', 45 | call: { 46 | methodName: 'func1', 47 | args: [] 48 | }, 49 | result: { 50 | filesVersion: 1, 51 | res: 1 52 | } 53 | }) 54 | t.deepEqual(await vm.callLog.get(2), { 55 | type: 'call', 56 | call: { 57 | methodName: 'func1', 58 | args: [5] 59 | }, 60 | result: { 61 | filesVersion: 1, 62 | res: 6 63 | } 64 | }) 65 | t.deepEqual(await vm.callLog.get(3), { 66 | type: 'call', 67 | call: { 68 | methodName: 'func1', 69 | args: [5], 70 | userId: 'bob' 71 | }, 72 | result: { 73 | filesVersion: 1, 74 | res: 6 75 | } 76 | }) 77 | t.deepEqual(await vm.callLog.get(4), { 78 | type: 'call', 79 | call: { 80 | methodName: 'func2', 81 | args: [] 82 | }, 83 | result: { 84 | filesVersion: 1, 85 | res: 'bar' 86 | } 87 | }) 88 | t.deepEqual(await vm.callLog.get(5), { 89 | type: 'call', 90 | call: { 91 | methodName: 'func2', 92 | args: [], 93 | userId: 'bob' 94 | }, 95 | result: { 96 | filesVersion: 1, 97 | res: 'bar' 98 | } 99 | }) 100 | }) 101 | 102 | test('file-writes are logged in the call log', async t => { 103 | // run calls 104 | await vm.executeCall({methodName: 'writeToFile', args: ['foo']}) 105 | await vm.executeCall({methodName: 'writeToFile', args: ['bar']}) 106 | await vm.executeCall({methodName: 'writeToFile', args: ['baz']}) 107 | 108 | // check call log 109 | t.deepEqual(await vm.callLog.get(6), { 110 | type: 'call', 111 | call: { 112 | methodName: 'writeToFile', 113 | args: ['foo'] 114 | }, 115 | result: { 116 | filesVersion: 2 117 | } 118 | }) 119 | t.deepEqual(await vm.callLog.get(7), { 120 | type: 'call', 121 | call: { 122 | methodName: 'writeToFile', 123 | args: ['bar'] 124 | }, 125 | result: { 126 | filesVersion: 3 127 | } 128 | }) 129 | t.deepEqual(await vm.callLog.get(8), { 130 | type: 'call', 131 | call: { 132 | methodName: 'writeToFile', 133 | args: ['baz'] 134 | }, 135 | result: { 136 | filesVersion: 4 137 | } 138 | }) 139 | }) 140 | 141 | test('can rebuild a VM from a call log', async t => { 142 | // initiate new vm from the call log 143 | const vm2 = await VM.fromCallLog(vm.callLog, {filesArchiveUrl: vm.filesArchive.url}) 144 | t.truthy(vm2.filesArchive, 'vm2 files archive created') 145 | t.truthy(vm2.callLog, 'vm2 call log created') 146 | 147 | // final output state is == 148 | await t.deepEqual(await vm2.filesArchive.readFile('/file'), 'baz', 'vm2 files are in expected state') 149 | 150 | // done 151 | await vm2.close() 152 | }) 153 | 154 | test('checks given files url', async t => { 155 | await t.throws(VM.fromCallLog(vm.callLog, {filesArchiveUrl: 'wrongurl'})) 156 | }) 157 | 158 | test('init method is called if present', async t => { 159 | // initiate vm 160 | vm = new VM(` 161 | var wasCalled = false 162 | exports.init = () => { wasCalled = true } 163 | exports.getWasCalled = () => wasCalled 164 | `) 165 | await vm.deploy({dir: tempy.directory(), title: 'test'}) 166 | t.truthy(vm.filesArchive, 'vm files archive created') 167 | t.truthy(vm.callLog, 'vm call log created') 168 | 169 | // check state 170 | t.deepEqual(await vm.executeCall({methodName: 'getWasCalled'}), true) 171 | 172 | // check call log 173 | t.deepEqual(await vm.callLog.get(1), { 174 | type: 'call', 175 | call: { 176 | methodName: 'init', 177 | args: [] 178 | }, 179 | result: { 180 | filesVersion: 1 181 | } 182 | }) 183 | }) 184 | 185 | test('can close the VM', async t => { 186 | // close vm 187 | await vm.close() 188 | t.falsy(vm.filesArchive) 189 | t.falsy(vm.callLog) 190 | }) -------------------------------------------------------------------------------- /lib/vm.js: -------------------------------------------------------------------------------- 1 | const NodeVM = require('vm') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const DatArchive = require('node-dat-archive') 5 | const tempy = require('tempy') 6 | const EventEmitter = require('events') 7 | const uuid = require('uuid/v4') 8 | const debug = require('debug')('vms') 9 | const CallLog = require('./call-log') 10 | const sandboxifyDatArchive = require('./sandboxify-dat-archive') 11 | 12 | const CURRENT_USER = Symbol('currentUser') 13 | 14 | class VM extends EventEmitter { 15 | constructor (code) { 16 | super() 17 | this.id = uuid() 18 | this.code = code // saved script 19 | this.dir = null // where on the local FS are we saving data 20 | this.addedAPIs = {} // APIs added by the host environment 21 | this.script = null // compiled script instance 22 | this.sandbox = null // the vm sandbox 23 | this.context = null // the vm context 24 | this.filesArchive = null // the vm's files archive 25 | this.callLog = null // the vm's call ledger 26 | this.hasEvaluated = false // has the script been evaluated yet? 27 | this.hasClosed = false 28 | 29 | // add the tests API in test mode 30 | if (process.env.NODE_ENV === 'test') { 31 | this.addAPI('test', { 32 | random: Math.random 33 | }) 34 | } 35 | } 36 | 37 | async close () { 38 | if (this.hasClosed) { 39 | return 40 | } 41 | this.hasClosed = true 42 | 43 | if (this.filesArchive) { 44 | await this.filesArchive._close() 45 | this.filesArchive = null 46 | } 47 | 48 | if (this.callLog) { 49 | await this.callLog.close() 50 | this.callLog = null 51 | } 52 | 53 | this.emit('close') 54 | } 55 | 56 | get exports () { 57 | return this.sandbox.exports 58 | } 59 | 60 | // addAPI adds a set of methods which will be made available on 61 | // the `System` global inside the script's vm 62 | // NOTE 63 | // don't use this API unless you understand oracles, and the 64 | // significance of oracle-handling code which hasn't been added yet 65 | // -prf 66 | addAPI (name, methods) { 67 | this.addedAPIs[name] = methods 68 | } 69 | 70 | // deploy sets up the files archive and the call log, 71 | // then evaluates the script so that it can accept commands 72 | async deploy ({dir, title, url}) { 73 | this.dir = dir 74 | var meta = readMetaFile(dir) 75 | 76 | if (meta && meta.url) { 77 | // check the url, if given 78 | if (url && meta.url !== url) { 79 | console.error('Mismatched files archive URL.') 80 | console.error(` Expected: ${url}`) 81 | console.error(` Found: ${meta.url}`) 82 | process.exit(1) 83 | } 84 | // files archive already exists 85 | debug('opening existing files directory at', dir) 86 | this.filesArchive = new DatArchive(meta.url, {localPath: dir}) 87 | await this.filesArchive._loadPromise 88 | this.callLog = await CallLog.open(dir) 89 | } else { 90 | // new files archive 91 | debug('creating new files directory at', dir) 92 | this.filesArchive = await DatArchive.create({ 93 | localPath: dir, 94 | title 95 | }) 96 | this.callLog = await CallLog.create(dir, this.code, this.filesArchive.url) 97 | writeMetaFile(dir, {title, url: this.filesArchive.url}) 98 | } 99 | 100 | // add the files archive API 101 | this.addAPI('files', sandboxifyDatArchive(this.filesArchive)) 102 | 103 | // evaluate the script 104 | evaluate(this) 105 | 106 | // call the script init 107 | if ('init' in this.sandbox.exports) { 108 | this.executeCall({methodName: 'init'}) 109 | } 110 | 111 | this.emit('ready') 112 | } 113 | 114 | // executeCall is run by two different components: 115 | // 1) the RPC server, due to a received command 116 | // 2) the `VM.fromCallLog` replay algorithm 117 | // NOTE you should not run a `vm.executeCall()` unless all previous calls have completed! 118 | // do not use executeCall unless you are confident you know what you are doing 119 | // -prf 120 | async executeCall ({methodName, args, userId}) { 121 | args = args || [] 122 | 123 | // update the caller info 124 | this[CURRENT_USER] = userId 125 | 126 | // execute the exported method 127 | var res, err 128 | try { 129 | res = await this.sandbox.exports[methodName](...args) 130 | } catch (e) { 131 | err = e 132 | } 133 | 134 | // log the results 135 | await this.callLog.appendCall({ 136 | userId, 137 | methodName, 138 | args, 139 | res, 140 | err, 141 | filesVersion: this.filesArchive._archive.version 142 | }) 143 | 144 | // return or throw for the RPC session 145 | if (err) throw err 146 | return res 147 | } 148 | 149 | // fromCallLog constructs a VM by replaying a call log 150 | // (the call log includes the vm's script) 151 | static async fromCallLog (callLog, assertions, {dir} = {}) { 152 | dir = dir || tempy.directory() 153 | 154 | // read the log 155 | const entries = await callLog.list() 156 | 157 | // handle init 158 | const initMsg = entries.shift() 159 | debug('init message', initMsg) 160 | if (initMsg.type !== 'init') { 161 | throw new Error(`Malformed call log: Expected "init" message, got ${initMsg.type}`) 162 | } 163 | if (initMsg.filesArchiveUrl !== assertions.filesArchiveUrl) { 164 | throw new Error(`Mismatched files archive URLs. Call log asserts ${initMsg.filesArchiveUrl}, server asserts ${assertions.filesArchiveUrl}`) 165 | } 166 | const vm = new VM(initMsg.code) 167 | await vm.deploy({dir, title: 'Replay'}) 168 | debug('backend script exports:', Object.keys(vm.exports)) 169 | 170 | // replay all remaining messages 171 | for (let i = 0; i < entries.length; i++) { 172 | let msg = entries[i] 173 | debug('replaying message', msg) 174 | if (msg.type !== 'call') { 175 | debug('unknown message type,', msg.type) 176 | } 177 | 178 | // TODO 179 | // wouldnt it make a lot of sense to just validate the res/err here instead of in a second loop (the Verifier) later? 180 | // -prf 181 | let {userId, methodName, args} = msg.call 182 | // let res, err 183 | try { 184 | /* res = */await vm.executeCall({methodName, args, userId}) 185 | } catch (e) { 186 | // err = e 187 | } 188 | } 189 | 190 | return vm 191 | } 192 | } 193 | 194 | // readMetaFiles pulls up the `meta.json` from the deployment directory 195 | function readMetaFile (dir) { 196 | // check the dir exists 197 | var stat 198 | try { 199 | stat = fs.statSync(dir) 200 | } catch (e) { 201 | return false 202 | } 203 | if (!stat.isDirectory()) { 204 | throw new Error('Target directory path is not a directory') 205 | } 206 | // load the meta.json 207 | try { 208 | var metaJson = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8')) 209 | } catch (e) { 210 | return false 211 | } 212 | return metaJson 213 | } 214 | 215 | // writeMetaFile writes the `meta.json` from the deployment directory 216 | function writeMetaFile (dir, content) { 217 | fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(content)) 218 | } 219 | 220 | // helper to evaluate a script 221 | function evaluate (vm) { 222 | if (vm.hasEvaluated) { 223 | return 224 | } 225 | vm.script = new NodeVM.Script(vm.code) 226 | vm.sandbox = createNewSandbox(vm) 227 | vm.context = NodeVM.createContext(vm.sandbox) 228 | vm.script.runInContext(vm.context) 229 | vm.hasEvaluated = true 230 | } 231 | 232 | // helper to construct the script's environment 233 | function createNewSandbox (vm) { 234 | var exports = {} 235 | 236 | // apis exported to the VM 237 | var System = { 238 | caller: { 239 | // these values are set on each invocation 240 | get id() { return vm[CURRENT_USER] } 241 | } 242 | } 243 | for (var api in vm.addedAPIs) { 244 | System[api] = vm.addedAPIs[api] 245 | } 246 | 247 | return { 248 | // exports 249 | module: {exports}, 250 | exports, 251 | 252 | // apis 253 | System, 254 | console, 255 | Buffer, 256 | setImmediate, 257 | setInterval, 258 | setTimeout, 259 | clearImmediate, 260 | clearInterval, 261 | clearTimeout 262 | } 263 | } 264 | 265 | module.exports = VM 266 | --------------------------------------------------------------------------------