/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://gre/modules/NetUtil.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("chrome://marionette/content/error.js"); const logger = Log.repository.getLogger("Marionette"); this.EXPORTED_SYMBOLS = ["evaluate", "sandbox", "Sandboxes"]; const ARGUMENTS = "__webDriverArguments"; const CALLBACK = "__webDriverCallback"; const COMPLETE = "__webDriverComplete"; const DEFAULT_TIMEOUT = 10000; // ms const FINISH = "finish"; const MARIONETTE_SCRIPT_FINISHED = "marionetteScriptFinished"; const ELEMENT_KEY = "element"; const W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; this.evaluate = {}; /** * Evaluate a script in given sandbox. * * If the option {@code directInject} is not specified, the script will * be executed as a function with the {@code args} argument applied. * * The arguments provided by the {@code args} argument are exposed through * the {@code arguments} object available in the script context, and if * the script is executed asynchronously with the {@code async} * option, an additional last argument that is synonymous to the * {@code marionetteScriptFinished} global is appended, and can be * accessed through {@code arguments[arguments.length - 1]}. * * The {@code timeout} option specifies the duration for how long the * script should be allowed to run before it is interrupted and aborted. * An interrupted script will cause a ScriptTimeoutError to occur. * * The {@code async} option indicates that the script will not return * until the {@code marionetteScriptFinished} global callback is invoked, * which is analogous to the last argument of the {@code arguments} * object. * * The option {@code directInject} causes the script to be evaluated * without being wrapped in a function and the provided arguments will * be disregarded. This will cause such things as root scope return * statements to throw errors because they are not used inside a function. * * The {@code filename} option is used in error messages to provide * information on the origin script file in the local end. * * The {@code line} option is used in error messages, along with * {@code filename}, to provide the line number in the origin script * file on the local end. * * @param {nsISandbox) sb * The sandbox the script will be evaluted in. * @param {string} script * The script to evaluate. * @param {Array.=} args * A sequence of arguments to call the script with. * @param {Object.=} opts * Dictionary of options: * * async (boolean) * Indicates if the script should return immediately or wait * for the callback be invoked before returning. * debug (boolean) * Attaches an {@code onerror} event listener. * directInject (boolean) * Evaluates the script without wrapping it in a function. * filename (string) * File location of the program in the client. * line (number) * Line number of the program in the client. * sandboxName (string) * Name of the sandbox. Elevated system privileges, equivalent * to chrome space, will be given if it is "system". * timeout (boolean) * Duration in milliseconds before interrupting the script. * * @return {Promise} * A promise that when resolved will give you the return value from * the script. Note that the return value requires serialisation before * it can be sent to the client. * * @throws JavaScriptError * If an Error was thrown whilst evaluating the script. * @throws ScriptTimeoutError * If the script was interrupted due to script timeout. */ evaluate.sandbox = function (sb, script, args = [], opts = {}) { let scriptTimeoutID, timeoutHandler, unloadHandler; let promise = new Promise((resolve, reject) => { let src = ""; sb[COMPLETE] = resolve; timeoutHandler = () => reject(new ScriptTimeoutError("Timed out")); unloadHandler = () => reject( new JavaScriptError("Document was unloaded during execution")); // wrap in function if (!opts.directInject) { if (opts.async) { sb[CALLBACK] = sb[COMPLETE]; } sb[ARGUMENTS] = sandbox.cloneInto(args, sb); // callback function made private // so that introspection is possible // on the arguments object if (opts.async) { sb[CALLBACK] = sb[COMPLETE]; src += `${ARGUMENTS}.push(rv => ${CALLBACK}(rv));`; } src += `(function() { ${script} }).apply(null, ${ARGUMENTS})`; // marionetteScriptFinished is not WebDriver conformant, // hence it is only exposed to immutable sandboxes if (opts.sandboxName) { sb[MARIONETTE_SCRIPT_FINISHED] = sb[CALLBACK]; } } // onerror is not hooked on by default because of the inability to // differentiate content errors from chrome errors. // // see bug 1128760 for more details if (opts.debug) { sb.window.onerror = (msg, url, line) => { let err = new JavaScriptError(`${msg} at ${url}:${line}`); reject(err); }; } // timeout and unload handlers scriptTimeoutID = setTimeout(timeoutHandler, opts.timeout || DEFAULT_TIMEOUT); sb.window.onunload = sandbox.cloneInto(unloadHandler, sb); let res; try { res = Cu.evalInSandbox(src, sb, "1.8", opts.filename || "dummy file", 0); } catch (e) { let err = new JavaScriptError( e, "execute_script", opts.filename, opts.line, script); reject(err); } if (!opts.async) { resolve(res); } }); return promise.then(res => { clearTimeout(scriptTimeoutID); sb.window.removeEventListener("unload", unloadHandler); return res; }); }; this.sandbox = {}; /** * Provides a safe way to take an object defined in a privileged scope and * create a structured clone of it in a less-privileged scope. It returns * a reference to the clone. * * Unlike for |Components.utils.cloneInto|, |obj| may contain functions * and DOM elemnets. */ sandbox.cloneInto = function (obj, sb) { return Cu.cloneInto(obj, sb, {cloneFunctions: true, wrapReflectors: true}); }; /** * Augment given sandbox by an adapter that has an {@code exports} * map property, or a normal map, of function names and function * references. * * @param {Sandbox} sb * The sandbox to augment. * @param {Object} adapter * Object that holds an {@code exports} property, or a map, of * function names and function references. * * @return {Sandbox} * The augmented sandbox. */ sandbox.augment = function (sb, adapter) { function* entries(obj) { for (let key of Object.keys(obj)) { yield [key, obj[key]]; } } let funcs = adapter.exports || entries(adapter); for (let [name, func] of funcs) { sb[name] = func; } return sb; }; /** * Creates a sandbox. * * @param {Window} window * The DOM Window object. * @param {nsIPrincipal=} principal * An optional, custom principal to prefer over the Window. Useful if * you need elevated security permissions. * * @return {Sandbox} * The created sandbox. */ sandbox.create = function (window, principal = null, opts = {}) { let p = principal || window; opts = Object.assign({ sameZoneAs: window, sandboxPrototype: window, wantComponents: true, wantXrays: true, }, opts); return new Cu.Sandbox(p, opts); }; /** * Creates a mutable sandbox, where changes to the global scope * will have lasting side-effects. * * @param {Window} window * The DOM Window object. * * @return {Sandbox} * The created sandbox. */ sandbox.createMutable = function (window) { let opts = { wantComponents: false, wantXrays: false, }; return sandbox.create(window, null, opts); }; sandbox.createSystemPrincipal = function (window) { let principal = Cc["@mozilla.org/systemprincipal;1"] .createInstance(Ci.nsIPrincipal); return sandbox.create(window, principal); }; sandbox.createSimpleTest = function (window, harness) { let sb = sandbox.create(window); sb = sandbox.augment(sb, harness); sb[FINISH] = () => sb[COMPLETE](harness.generate_results()); return sb; }; /** * Sandbox storage. When the user requests a sandbox by a specific name, * if one exists in the storage this will be used as long as its window * reference is still valid. */ this.Sandboxes = class { /** * @param {function(): Window} windowFn * A function that returns the references to the current Window * object. */ constructor(windowFn) { this.windowFn_ = windowFn; this.boxes_ = new Map(); } get window_() { return this.windowFn_(); } /** * Factory function for getting a sandbox by name, or failing that, * creating a new one. * * If the sandbox' window does not match the provided window, a new one * will be created. * * @param {string} name * The name of the sandbox to get or create. * @param {boolean} fresh * Remove old sandbox by name first, if it exists. * * @return {Sandbox} * A used or fresh sandbox. */ get(name = "default", fresh = false) { let sb = this.boxes_.get(name); if (sb) { if (fresh || sb.window != this.window_) { this.boxes_.delete(name); return this.get(name, false); } } else { if (name == "system") { sb = sandbox.createSystemPrincipal(this.window_); } else { sb = sandbox.create(this.window_); } this.boxes_.set(name, sb); } return sb; } /** * Clears cache of sandboxes. */ clear() { this.boxes_.clear(); } }; /** * Stores scripts imported from the local end through the * {@code GeckoDriver#importScript} command. * * Imported scripts are prepended to the script that is evaluated * on each call to {@code GeckoDriver#executeScript}, * {@code GeckoDriver#executeAsyncScript}, and * {@code GeckoDriver#executeJSScript}. * * Usage: * * let importedScripts = new evaluate.ScriptStorage(); * importedScripts.add(firstScript); * importedScripts.add(secondScript); * * let scriptToEval = importedScripts.concat(script); * // firstScript and secondScript are prepended to script * */ evaluate.ScriptStorage = class extends Set { /** * Produce a string of all stored scripts. * * The stored scripts are concatenated into a string, with optional * additional scripts then appended. * * @param {...string} addional * Optional scripts to include. * * @return {string} * Concatenated string consisting of stored scripts and additional * scripts, in that order. */ concat(...additional) { let rv = ""; for (let s of this) { rv = s + rv; } for (let s of additional) { rv = rv + s; } return rv; } toJson() { return Array.from(this); } }; /** * Service that enables the script storage service to be queried from * content space. * * The storage can back multiple |ScriptStorage|, each typically belonging * to a |Context|. Since imported scripts' scope are global and not * scoped to the current browsing context, all imported scripts are stored * in chrome space and fetched by content space as needed. * * Usage in chrome space: * * let service = new evaluate.ScriptStorageService( * [Context.CHROME, Context.CONTENT]); * let storage = service.for(Context.CHROME); * let scriptToEval = storage.concat(script); * */ evaluate.ScriptStorageService = class extends Map { /** * Create the service. * * An optional array of names for script storages to initially create * can be provided. * * @param {Array.=} initialStorages * List of names of the script storages to create initially. */ constructor(initialStorages = []) { super(initialStorages.map(name => [name, new evaluate.ScriptStorage()])); } /** * Retrieve the scripts associated with the given context. * * @param {Context} context * Context to retrieve the scripts from. * * @return {ScriptStorage} * Scrips associated with given |context|. */ for(context) { return this.get(context); } processMessage(msg) { switch (msg.name) { case "Marionette:getImportedScripts": let storage = this.for.apply(this, msg.json); return storage.toJson(); default: throw new TypeError("Unknown message: " + msg.name); } } // TODO(ato): The idea of services in chrome space // can be generalised at some later time (see cookies.js:38). receiveMessage(msg) { try { return this.processMessage(msg); } catch (e) { logger.error(e); } } }; evaluate.ScriptStorageService.prototype.QueryInterface = XPCOMUtils.generateQI([ Ci.nsIMessageListener, Ci.nsISupportsWeakReference, ]); /** * Bridges the script storage in chrome space, to make it possible to * retrieve a {@code ScriptStorage} associated with a given * {@code Context} from content space. * * Usage in content space: * * let client = new evaluate.ScriptStorageServiceClient(chromeProxy); * let storage = client.for(Context.CONTENT); * let scriptToEval = storage.concat(script); * */ evaluate.ScriptStorageServiceClient = class { /** * @param {proxy.SyncChromeSender} chromeProxy * Proxy for communicating with chrome space. */ constructor(chromeProxy) { this.chrome = chromeProxy; } /** * Retrieve scripts associated with the given context. * * @param {Context} context * Context to retrieve scripts from. * * @return {ScriptStorage} * Scripts associated with given |context|. */ for(context) { let scripts = this.chrome.getImportedScripts(context)[0]; return new evaluate.ScriptStorage(scripts); } };