libvms
Version:
API for running cryptographically auditable VMs.
266 lines (231 loc) • 7.15 kB
JavaScript
const NodeVM = require('vm')
const path = require('path')
const fs = require('fs')
const DatArchive = require('node-dat-archive')
const tempy = require('tempy')
const EventEmitter = require('events')
const uuid = require('uuid/v4')
const debug = require('debug')('vms')
const CallLog = require('./call-log')
const sandboxifyDatArchive = require('./sandboxify-dat-archive')
const CURRENT_USER = Symbol('currentUser')
class VM extends EventEmitter {
constructor (code) {
super()
this.id = uuid()
this.code = code // saved script
this.dir = null // where on the local FS are we saving data
this.addedAPIs = {} // APIs added by the host environment
this.script = null // compiled script instance
this.sandbox = null // the vm sandbox
this.context = null // the vm context
this.filesArchive = null // the vm's files archive
this.callLog = null // the vm's call ledger
this.hasEvaluated = false // has the script been evaluated yet?
this.hasClosed = false
// add the tests API in test mode
if (process.env.NODE_ENV === 'test') {
this.addAPI('test', {
random: Math.random
})
}
}
async close () {
if (this.hasClosed) {
return
}
this.hasClosed = true
if (this.filesArchive) {
await this.filesArchive._close()
this.filesArchive = null
}
if (this.callLog) {
await this.callLog.close()
this.callLog = null
}
this.emit('close')
}
get exports () {
return this.sandbox.exports
}
// addAPI adds a set of methods which will be made available on
// the `System` global inside the script's vm
// NOTE
// don't use this API unless you understand oracles, and the
// significance of oracle-handling code which hasn't been added yet
// -prf
addAPI (name, methods) {
this.addedAPIs[name] = methods
}
// deploy sets up the files archive and the call log,
// then evaluates the script so that it can accept commands
async deploy ({dir, title, url}) {
this.dir = dir
var meta = readMetaFile(dir)
if (meta && meta.url) {
// check the url, if given
if (url && meta.url !== url) {
console.error('Mismatched files archive URL.')
console.error(` Expected: ${url}`)
console.error(` Found: ${meta.url}`)
process.exit(1)
}
// files archive already exists
debug('opening existing files directory at', dir)
this.filesArchive = new DatArchive(meta.url, {localPath: dir})
await this.filesArchive._loadPromise
this.callLog = await CallLog.open(dir)
} else {
// new files archive
debug('creating new files directory at', dir)
this.filesArchive = await DatArchive.create({
localPath: dir,
title
})
this.callLog = await CallLog.create(dir, this.code, this.filesArchive.url)
writeMetaFile(dir, {title, url: this.filesArchive.url})
}
// add the files archive API
this.addAPI('files', sandboxifyDatArchive(this.filesArchive))
// evaluate the script
evaluate(this)
// call the script init
if ('init' in this.sandbox.exports) {
this.executeCall({methodName: 'init'})
}
this.emit('ready')
}
// executeCall is run by two different components:
// 1) the RPC server, due to a received command
// 2) the `VM.fromCallLog` replay algorithm
// NOTE you should not run a `vm.executeCall()` unless all previous calls have completed!
// do not use executeCall unless you are confident you know what you are doing
// -prf
async executeCall ({methodName, args, userId}) {
args = args || []
// update the caller info
this[CURRENT_USER] = userId
// execute the exported method
var res, err
try {
res = await this.sandbox.exports[methodName](...args)
} catch (e) {
err = e
}
// log the results
await this.callLog.appendCall({
userId,
methodName,
args,
res,
err,
filesVersion: this.filesArchive._archive.version
})
// return or throw for the RPC session
if (err) throw err
return res
}
// fromCallLog constructs a VM by replaying a call log
// (the call log includes the vm's script)
static async fromCallLog (callLog, assertions, {dir} = {}) {
dir = dir || tempy.directory()
// read the log
const entries = await callLog.list()
// handle init
const initMsg = entries.shift()
debug('init message', initMsg)
if (initMsg.type !== 'init') {
throw new Error(`Malformed call log: Expected "init" message, got ${initMsg.type}`)
}
if (initMsg.filesArchiveUrl !== assertions.filesArchiveUrl) {
throw new Error(`Mismatched files archive URLs. Call log asserts ${initMsg.filesArchiveUrl}, server asserts ${assertions.filesArchiveUrl}`)
}
const vm = new VM(initMsg.code)
await vm.deploy({dir, title: 'Replay'})
debug('backend script exports:', Object.keys(vm.exports))
// replay all remaining messages
for (let i = 0; i < entries.length; i++) {
let msg = entries[i]
debug('replaying message', msg)
if (msg.type !== 'call') {
debug('unknown message type,', msg.type)
}
// TODO
// wouldnt it make a lot of sense to just validate the res/err here instead of in a second loop (the Verifier) later?
// -prf
let {userId, methodName, args} = msg.call
// let res, err
try {
/* res = */await vm.executeCall({methodName, args, userId})
} catch (e) {
// err = e
}
}
return vm
}
}
// readMetaFiles pulls up the `meta.json` from the deployment directory
function readMetaFile (dir) {
// check the dir exists
var stat
try {
stat = fs.statSync(dir)
} catch (e) {
return false
}
if (!stat.isDirectory()) {
throw new Error('Target directory path is not a directory')
}
// load the meta.json
try {
var metaJson = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8'))
} catch (e) {
return false
}
return metaJson
}
// writeMetaFile writes the `meta.json` from the deployment directory
function writeMetaFile (dir, content) {
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(content))
}
// helper to evaluate a script
function evaluate (vm) {
if (vm.hasEvaluated) {
return
}
vm.script = new NodeVM.Script(vm.code)
vm.sandbox = createNewSandbox(vm)
vm.context = NodeVM.createContext(vm.sandbox)
vm.script.runInContext(vm.context)
vm.hasEvaluated = true
}
// helper to construct the script's environment
function createNewSandbox (vm) {
var exports = {}
// apis exported to the VM
var System = {
caller: {
// these values are set on each invocation
get id() { return vm[CURRENT_USER] }
}
}
for (var api in vm.addedAPIs) {
System[api] = vm.addedAPIs[api]
}
return {
// exports
module: {exports},
exports,
// apis
System,
console,
Buffer,
setImmediate,
setInterval,
setTimeout,
clearImmediate,
clearInterval,
clearTimeout
}
}
module.exports = VM