const _ = require('lodash'), uuid = require('./vendor/uuid'), UniversalVM = require('uvm'), PostmanEvent = require('postman-collection').Event, teleportJS = require('teleport-javascript'), bootcode = require('./bootcode'), TO_WAIT_BUFFER = 500, // time to wait for sandbox to declare timeout EXECUTION_TIMEOUT_ERROR_MESSAGE = 'sandbox not responding', BRIDGE_DISCONNECTING_ERROR_MESSAGE = 'sandbox: execution interrupted, bridge disconnecting.'; class PostmanSandbox extends UniversalVM { constructor () { super(); this._executing = {}; } initialize (options, callback) { // ensure options is an object and is shallow cloned options = _.assign({}, options); this.debug = Boolean(options.debug); // set the dispatch timeout of UVM based on what is set in options unless original options sends the same _.isFinite(options.timeout) && (options.dispatchTimeout = this.executionTimeout = options.timeout); super.connect(options, (err, context) => { if (err) { return callback(err); } context.ping((err) => { // eslint-disable-next-line callback-return callback(err, context); context = null; }); }); } ping (callback) { const packet = uuid(), start = Date.now(); this.once('pong', (echo) => { callback((echo !== packet ? new Error('sandbox: ping packet mismatch') : null), Date.now() - start, packet); }); this.dispatch('ping', packet); } /** * @param {Event|String} target - can optionally be the code to execute * @param {Object} options - * @param {String} options.id - * @param {Boolean} options.debug - * @param {Number} options.timeout - * @param {Object} options.cursor - * @param {Object} options.context - * @param {Boolean} options.serializeLogs - * @param {Function} callback - */ execute (target, options, callback) { if (_.isFunction(options) && !callback) { callback = options; options = null; } !_.isObject(options) && (options = {}); !_.isFunction(callback) && (callback = _.noop); // if the target is simple code, we make a generic event out of it if (_.isString(target) || _.isArray(target)) { target = new PostmanEvent({ script: target }); } // if target is not a code and instead is not something that can be cast to an event, it is definitely an error else if (!_.isObject(target)) { return callback(new Error('sandbox: no target provided for execution')); } const id = _.isString(options.id) ? options.id : uuid(), executionEventName = 'execution.result.' + id, consoleEventName = 'execution.console.' + id, executionTimeout = _.get(options, 'timeout', this.executionTimeout), cursor = _.clone(_.get(options, 'cursor', {})), // clone the cursor as it travels through IPC for mutation debugMode = _.has(options, 'debug') ? options.debug : this.debug; let waiting; // set the execution id in cursor cursor.execution = id; // set execution timeout and store the interrupt in a global object (so that we can clear during dispose) // force trigger of the `execution.${id}` event so that the normal error flow is taken this._executing[id] = _.isFinite(executionTimeout) ? (waiting = setTimeout(() => { waiting = null; this.emit.bind(executionEventName, new Error(EXECUTION_TIMEOUT_ERROR_MESSAGE)); }, executionTimeout + TO_WAIT_BUFFER)) : null; // @todo decide how the results will return in a more managed fashion // listen to this once, so that subsequent calls are simply dropped. especially during timeout and other // errors this.once(executionEventName, (err, result) => { waiting && (waiting = clearTimeout(waiting)); // clear timeout interrupt if (Object.hasOwnProperty.call(this._executing, id)) { // clear any pending timeouts this._executing[id] && clearTimeout(this._executing[id]); delete this._executing[id]; } this.emit('execution', err, id, result); callback(err, result); }); this.on(consoleEventName, (cursor, level, args) => { if (_.get(options, 'serializeLogs')) { return this.emit('console', cursor, level, args); } args = teleportJS.parse(args); args.unshift('console', cursor, level); // eslint-disable-next-line prefer-spread this.emit.apply(this, args); }); // send the code to the sandbox to be intercepted and executed this.dispatch('execute', id, target, _.get(options, 'context', {}), { cursor: cursor, debug: debugMode, timeout: executionTimeout, legacy: _.get(options, 'legacy') }); } dispose () { _.forEach(this._executing, (irq, id) => { irq && clearTimeout(irq); // send an abort event to the sandbox so that it can do cleanups this.dispatch('execution.abort.' + id); // even though sandbox could bubble the result event upon receiving abort, that would reduce // stability of the system in case sandbox was unresponsive. this.emit('execution.result.' + id, new Error(BRIDGE_DISCONNECTING_ERROR_MESSAGE)); }); this.disconnect(); } } module.exports = { /** * Creates a new instance of sandbox from the options that have been provided * * @param {Object=} [options] - * @param {Function} callback - */ createContext (options, callback) { if (_.isFunction(options) && !callback) { callback = options; options = {}; } options = _.clone(options); bootcode((err, code) => { if (err) { return callback(err); } if (!code) { return callback(new Error('sandbox: bootcode missing!')); } options.bootCode = code; // assign the code in options new PostmanSandbox().initialize(options, callback); }); } };