From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- devtools/server/actors/storage.js | 2542 +++++++++++++++++++++++++++++++++++++ 1 file changed, 2542 insertions(+) create mode 100644 devtools/server/actors/storage.js (limited to 'devtools/server/actors/storage.js') diff --git a/devtools/server/actors/storage.js b/devtools/server/actors/storage.js new file mode 100644 index 000000000..572cd6b68 --- /dev/null +++ b/devtools/server/actors/storage.js @@ -0,0 +1,2542 @@ +/* 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 {Cc, Ci, Cu, CC} = require("chrome"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {DebuggerServer} = require("devtools/server/main"); +const Services = require("Services"); +const promise = require("promise"); +const {isWindowIncluded} = require("devtools/shared/layout/utils"); +const specs = require("devtools/shared/specs/storage"); +const { Task } = require("devtools/shared/task"); + +loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm"); +loader.lazyImporter(this, "Sqlite", "resource://gre/modules/Sqlite.jsm"); + +// We give this a funny name to avoid confusion with the global +// indexedDB. +loader.lazyGetter(this, "indexedDBForStorage", () => { + // On xpcshell, we can't instantiate indexedDB without crashing + try { + let sandbox + = Cu.Sandbox(CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(), + {wantGlobalProperties: ["indexedDB"]}); + return sandbox.indexedDB; + } catch (e) { + return {}; + } +}); + +// Maximum number of cookies/local storage key-value-pairs that can be sent +// over the wire to the client in one request. +const MAX_STORE_OBJECT_COUNT = 50; +// Delay for the batch job that sends the accumulated update packets to the +// client (ms). +const BATCH_DELAY = 200; + +// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that +// precision. +const MAX_COOKIE_EXPIRY = Math.pow(2, 62); + +// A RegExp for characters that cannot appear in a file/directory name. This is +// used to sanitize the host name for indexed db to lookup whether the file is +// present in /storage/default/ location +var illegalFileNameCharacters = [ + "[", + // Control characters \001 to \036 + "\\x00-\\x24", + // Special characters + "/:*?\\\"<>|\\\\", + "]" +].join(""); +var ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g"); + +// Holder for all the registered storage actors. +var storageTypePool = new Map(); + +/** + * An async method equivalent to setTimeout but using Promises + * + * @param {number} time + * The wait time in milliseconds. + */ +function sleep(time) { + let deferred = promise.defer(); + + setTimeout(() => { + deferred.resolve(null); + }, time); + + return deferred.promise; +} + +// Helper methods to create a storage actor. +var StorageActors = {}; + +/** + * Creates a default object with the common methods required by all storage + * actors. + * + * This default object is missing a couple of required methods that should be + * implemented seperately for each actor. They are namely: + * - observe : Method which gets triggered on the notificaiton of the watched + * topic. + * - getNamesForHost : Given a host, get list of all known store names. + * - getValuesForHost : Given a host (and optianally a name) get all known + * store objects. + * - toStoreObject : Given a store object, convert it to the required format + * so that it can be transferred over wire. + * - populateStoresForHost : Given a host, populate the map of all store + * objects for it + * - getFields: Given a subType(optional), get an array of objects containing + * column field info. The info includes, + * "name" is name of colume key. + * "editable" is 1 means editable field; 0 means uneditable. + * + * @param {string} typeName + * The typeName of the actor. + * @param {string} observationTopic + * The topic which this actor listens to via Notification Observers. + */ +StorageActors.defaults = function (typeName, observationTopic) { + return { + typeName: typeName, + + get conn() { + return this.storageActor.conn; + }, + + /** + * Returns a list of currently knwon hosts for the target window. This list + * contains unique hosts from the window + all inner windows. + */ + get hosts() { + let hosts = new Set(); + for (let {location} of this.storageActor.windows) { + hosts.add(this.getHostName(location)); + } + return hosts; + }, + + /** + * Returns all the windows present on the page. Includes main window + inner + * iframe windows. + */ + get windows() { + return this.storageActor.windows; + }, + + /** + * Converts the window.location object into host. + */ + getHostName(location) { + return location.hostname || location.href; + }, + + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.populateStoresForHosts(); + if (observationTopic) { + Services.obs.addObserver(this, observationTopic, false); + } + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + if (observationTopic) { + Services.obs.removeObserver(this, observationTopic, false); + } + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + + this.hostVsStores.clear(); + this.storageActor = null; + }, + + getNamesForHost(host) { + return [...this.hostVsStores.get(host).keys()]; + }, + + getValuesForHost(host, name) { + if (name) { + return [this.hostVsStores.get(host).get(name)]; + } + return [...this.hostVsStores.get(host).values()]; + }, + + getObjectsSize(host, names) { + return names.length; + }, + + /** + * When a new window is added to the page. This generally means that a new + * iframe is created, or the current window is completely reloaded. + * + * @param {window} window + * The window which was added. + */ + onWindowReady: Task.async(function* (window) { + let host = this.getHostName(window.location); + if (!this.hostVsStores.has(host)) { + yield this.populateStoresForHost(host, window); + let data = {}; + data[host] = this.getNamesForHost(host); + this.storageActor.update("added", typeName, data); + } + }), + + /** + * When a window is removed from the page. This generally means that an + * iframe was removed, or the current window reload is triggered. + * + * @param {window} window + * The window which was removed. + */ + onWindowDestroyed(window) { + if (!window.location) { + // Nothing can be done if location object is null + return; + } + let host = this.getHostName(window.location); + if (!this.hosts.has(host)) { + this.hostVsStores.delete(host); + let data = {}; + data[host] = []; + this.storageActor.update("deleted", typeName, data); + } + }, + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = []; + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + /** + * Populates a map of known hosts vs a map of stores vs value. + */ + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (let host of this.hosts) { + this.populateStoresForHost(host); + } + }, + + /** + * Returns a list of requested store objects. Maximum values returned are + * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose + * starting index and total size can be controlled via the options object + * + * @param {string} host + * The host name for which the store values are required. + * @param {array:string} names + * Array containing the names of required store objects. Empty if all + * items are required. + * @param {object} options + * Additional options for the request containing following + * properties: + * - offset {number} : The begin index of the returned array amongst + * the total values + * - size {number} : The number of values required. + * - sortOn {string} : The values should be sorted on this property. + * - index {string} : In case of indexed db, the IDBIndex to be used + * for fetching the values. + * + * @return {object} An object containing following properties: + * - offset - The actual offset of the returned array. This might + * be different from the requested offset if that was + * invalid + * - total - The total number of entries possible. + * - data - The requested values. + */ + getStoreObjects: Task.async(function* (host, names, options = {}) { + let offset = options.offset || 0; + let size = options.size || MAX_STORE_OBJECT_COUNT; + if (size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + let sortOn = options.sortOn || "name"; + + let toReturn = { + offset: offset, + total: 0, + data: [] + }; + + let principal = null; + if (this.typeName === "indexedDB") { + // We only acquire principal when the type of the storage is indexedDB + // because the principal only matters the indexedDB. + let win = this.storageActor.getWindowFromHost(host); + if (win) { + principal = win.document.nodePrincipal; + } + } + + if (names) { + for (let name of names) { + let values = yield this.getValuesForHost(host, name, options, + this.hostVsStores, principal); + + let {result, objectStores} = values; + + if (result && typeof result.objectsSize !== "undefined") { + for (let {key, count} of result.objectsSize) { + this.objectsSize[key] = count; + } + } + + if (result) { + toReturn.data.push(...result.data); + } else if (objectStores) { + toReturn.data.push(...objectStores); + } else { + toReturn.data.push(...values); + } + } + toReturn.total = this.getObjectsSize(host, names, options); + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = toReturn.total; + toReturn.data = []; + } else { + toReturn.data = toReturn.data.sort((a, b) => { + return a[sortOn] - b[sortOn]; + }).slice(offset, offset + size).map(a => this.toStoreObject(a)); + } + } else { + let obj = yield this.getValuesForHost(host, undefined, undefined, + this.hostVsStores, principal); + if (obj.dbs) { + obj = obj.dbs; + } + + toReturn.total = obj.length; + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = offset = toReturn.total; + toReturn.data = []; + } else { + toReturn.data = obj.sort((a, b) => { + return a[sortOn] - b[sortOn]; + }).slice(offset, offset + size) + .map(object => this.toStoreObject(object)); + } + } + + return toReturn; + }) + }; +}; + +/** + * Creates an actor and its corresponding front and registers it to the Storage + * Actor. + * + * @See StorageActors.defaults() + * + * @param {object} options + * Options required by StorageActors.defaults method which are : + * - typeName {string} + * The typeName of the actor. + * - observationTopic {string} + * The topic which this actor listens to via + * Notification Observers. + * @param {object} overrides + * All the methods which you want to be different from the ones in + * StorageActors.defaults method plus the required ones described there. + */ +StorageActors.createActor = function (options = {}, overrides = {}) { + let actorObject = StorageActors.defaults( + options.typeName, + options.observationTopic || null + ); + for (let key in overrides) { + actorObject[key] = overrides[key]; + } + + let actorSpec = specs.childSpecs[options.typeName]; + let actor = protocol.ActorClassWithSpec(actorSpec, actorObject); + storageTypePool.set(actorObject.typeName, actor); +}; + +/** + * The Cookies actor and front. + */ +StorageActors.createActor({ + typeName: "cookies" +}, { + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.maybeSetupChildProcess(); + this.populateStoresForHosts(); + this.addCookieObservers(); + + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + this.hostVsStores.clear(); + + // We need to remove the cookie listeners early in E10S mode so we need to + // use a conditional here to ensure that we only attempt to remove them in + // single process mode. + if (!DebuggerServer.isInChildProcess) { + this.removeCookieObservers(); + } + + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + + this._pendingResponse = this.storageActor = null; + }, + + /** + * Given a cookie object, figure out all the matching hosts from the page that + * the cookie belong to. + */ + getMatchingHosts(cookies) { + if (!cookies.length) { + cookies = [cookies]; + } + let hosts = new Set(); + for (let host of this.hosts) { + for (let cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + hosts.add(host); + } + } + } + return [...hosts]; + }, + + /** + * Given a cookie object and a host, figure out if the cookie is valid for + * that host. + */ + isCookieAtHost(cookie, host) { + if (cookie.host == null) { + return host == null; + } + if (cookie.host.startsWith(".")) { + return ("." + host).endsWith(cookie.host); + } + if (cookie.host === "") { + return host.startsWith("file://" + cookie.path); + } + return cookie.host == host; + }, + + toStoreObject(cookie) { + if (!cookie) { + return null; + } + + return { + name: cookie.name, + path: cookie.path || "", + host: cookie.host || "", + + // because expires is in seconds + expires: (cookie.expires || 0) * 1000, + + // because it is in micro seconds + creationTime: cookie.creationTime / 1000, + + // - do - + lastAccessed: cookie.lastAccessed / 1000, + value: new LongStringActor(this.conn, cookie.value || ""), + isDomain: cookie.isDomain, + isSecure: cookie.isSecure, + isHttpOnly: cookie.isHttpOnly + }; + }, + + populateStoresForHost(host) { + this.hostVsStores.set(host, new Map()); + let doc = this.storageActor.document; + + let cookies = this.getCookiesFromHost(host, doc.nodePrincipal + .originAttributes); + + for (let cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + this.hostVsStores.get(host).set(cookie.name, cookie); + } + } + }, + + /** + * Notification observer for "cookie-change". + * + * @param subject + * {Cookie|[Array]} A JSON parsed object containing either a single + * cookie representation or an array. Array is only in case of + * a "batch-deleted" action. + * @param {string} topic + * The topic of the notification. + * @param {string} action + * Additional data associated with the notification. Its the type of + * cookie change in the "cookie-change" topic. + */ + onCookieChanged(subject, topic, action) { + if (topic !== "cookie-changed" || + !this.storageActor || + !this.storageActor.windows) { + return null; + } + + let hosts = this.getMatchingHosts(subject); + let data = {}; + + switch (action) { + case "added": + case "changed": + if (hosts.length) { + for (let host of hosts) { + this.hostVsStores.get(host).set(subject.name, subject); + data[host] = [subject.name]; + } + this.storageActor.update(action, "cookies", data); + } + break; + + case "deleted": + if (hosts.length) { + for (let host of hosts) { + this.hostVsStores.get(host).delete(subject.name); + data[host] = [subject.name]; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case "batch-deleted": + if (hosts.length) { + for (let host of hosts) { + let stores = []; + for (let cookie of subject) { + this.hostVsStores.get(host).delete(cookie.name); + stores.push(cookie.name); + } + data[host] = stores; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case "cleared": + if (hosts.length) { + for (let host of hosts) { + data[host] = []; + } + this.storageActor.update("cleared", "cookies", data); + } + break; + } + return null; + }, + + getFields: Task.async(function* () { + return [ + { name: "name", editable: 1}, + { name: "path", editable: 1}, + { name: "host", editable: 1}, + { name: "expires", editable: 1}, + { name: "lastAccessed", editable: 0}, + { name: "value", editable: 1}, + { name: "isDomain", editable: 0}, + { name: "isSecure", editable: 1}, + { name: "isHttpOnly", editable: 1} + ]; + }), + + /** + * Pass the editItem command from the content to the chrome process. + * + * @param {Object} data + * See editCookie() for format details. + */ + editItem: Task.async(function* (data) { + let doc = this.storageActor.document; + data.originAttributes = doc.nodePrincipal + .originAttributes; + this.editCookie(data); + }), + + removeItem: Task.async(function* (host, name) { + let doc = this.storageActor.document; + this.removeCookie(host, name, doc.nodePrincipal + .originAttributes); + }), + + removeAll: Task.async(function* (host, domain) { + let doc = this.storageActor.document; + this.removeAllCookies(host, domain, doc.nodePrincipal + .originAttributes); + }), + + maybeSetupChildProcess() { + cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this); + + if (!DebuggerServer.isInChildProcess) { + this.getCookiesFromHost = + cookieHelpers.getCookiesFromHost.bind(cookieHelpers); + this.addCookieObservers = + cookieHelpers.addCookieObservers.bind(cookieHelpers); + this.removeCookieObservers = + cookieHelpers.removeCookieObservers.bind(cookieHelpers); + this.editCookie = + cookieHelpers.editCookie.bind(cookieHelpers); + this.removeCookie = + cookieHelpers.removeCookie.bind(cookieHelpers); + this.removeAllCookies = + cookieHelpers.removeAllCookies.bind(cookieHelpers); + return; + } + + const { sendSyncMessage, addMessageListener } = + this.conn.parentMessageManager; + + this.conn.setupInParent({ + module: "devtools/server/actors/storage", + setupParent: "setupParentProcessForCookies" + }); + + this.getCookiesFromHost = + callParentProcess.bind(null, "getCookiesFromHost"); + this.addCookieObservers = + callParentProcess.bind(null, "addCookieObservers"); + this.removeCookieObservers = + callParentProcess.bind(null, "removeCookieObservers"); + this.editCookie = + callParentProcess.bind(null, "editCookie"); + this.removeCookie = + callParentProcess.bind(null, "removeCookie"); + this.removeAllCookies = + callParentProcess.bind(null, "removeAllCookies"); + + addMessageListener("debug:storage-cookie-request-child", + cookieHelpers.handleParentRequest); + + function callParentProcess(methodName, ...args) { + let reply = sendSyncMessage("debug:storage-cookie-request-parent", { + method: methodName, + args: args + }); + + if (reply.length === 0) { + console.error("ERR_DIRECTOR_CHILD_NO_REPLY from " + methodName); + } else if (reply.length > 1) { + console.error("ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES from " + methodName); + } + + let result = reply[0]; + + if (methodName === "getCookiesFromHost") { + return JSON.parse(result); + } + + return result; + } + }, +}); + +var cookieHelpers = { + getCookiesFromHost(host, originAttributes) { + // Local files have no host. + if (host.startsWith("file:///")) { + host = ""; + } + + let cookies = Services.cookies.getCookiesFromHost(host, originAttributes); + let store = []; + + while (cookies.hasMoreElements()) { + let cookie = cookies.getNext().QueryInterface(Ci.nsICookie2); + + store.push(cookie); + } + + return store; + }, + + /** + * Apply the results of a cookie edit. + * + * @param {Object} data + * An object in the following format: + * { + * host: "http://www.mozilla.org", + * field: "value", + * key: "name", + * oldValue: "%7BHello%7D", + * newValue: "%7BHelloo%7D", + * items: { + * name: "optimizelyBuckets", + * path: "/", + * host: ".mozilla.org", + * expires: "Mon, 02 Jun 2025 12:37:37 GMT", + * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT", + * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT", + * value: "%7BHelloo%7D", + * isDomain: "true", + * isSecure: "false", + * isHttpOnly: "false" + * } + * } + */ + editCookie(data) { + let {field, oldValue, newValue} = data; + let origName = field === "name" ? oldValue : data.items.name; + let origHost = field === "host" ? oldValue : data.items.host; + let origPath = field === "path" ? oldValue : data.items.path; + let cookie = null; + + let enumerator = Services.cookies.getCookiesFromHost(origHost, data.originAttributes || {}); + while (enumerator.hasMoreElements()) { + let nsiCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (nsiCookie.name === origName && nsiCookie.host === origHost) { + cookie = { + host: nsiCookie.host, + path: nsiCookie.path, + name: nsiCookie.name, + value: nsiCookie.value, + isSecure: nsiCookie.isSecure, + isHttpOnly: nsiCookie.isHttpOnly, + isSession: nsiCookie.isSession, + expires: nsiCookie.expires, + originAttributes: nsiCookie.originAttributes + }; + break; + } + } + + if (!cookie) { + return; + } + + // If the date is expired set it for 1 minute in the future. + let now = new Date(); + if (!cookie.isSession && (cookie.expires * 1000) <= now) { + let tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000; + + cookie.expires = tenSecondsFromNow; + } + + switch (field) { + case "isSecure": + case "isHttpOnly": + case "isSession": + newValue = newValue === "true"; + break; + + case "expires": + newValue = Date.parse(newValue) / 1000; + + if (isNaN(newValue)) { + newValue = MAX_COOKIE_EXPIRY; + } + break; + + case "host": + case "name": + case "path": + // Remove the edited cookie. + Services.cookies.remove(origHost, origName, origPath, + false, cookie.originAttributes); + break; + } + + // Apply changes. + cookie[field] = newValue; + + // cookie.isSession is not always set correctly on session cookies so we + // need to trust cookie.expires instead. + cookie.isSession = !cookie.expires; + + // Add the edited cookie. + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + cookie.value, + cookie.isSecure, + cookie.isHttpOnly, + cookie.isSession, + cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, + cookie.originAttributes + ); + }, + + _removeCookies(host, opts = {}) { + function hostMatches(cookieHost, matchHost) { + if (cookieHost == null) { + return matchHost == null; + } + if (cookieHost.startsWith(".")) { + return ("." + matchHost).endsWith(cookieHost); + } + return cookieHost == host; + } + + let enumerator = Services.cookies.getCookiesFromHost(host, opts.originAttributes || {}); + while (enumerator.hasMoreElements()) { + let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (hostMatches(cookie.host, host) && + (!opts.name || cookie.name === opts.name) && + (!opts.domain || cookie.host === opts.domain)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + false, + cookie.originAttributes + ); + } + } + }, + + removeCookie(host, name, originAttributes) { + if (name !== undefined) { + this._removeCookies(host, { name, originAttributes }); + } + }, + + removeAllCookies(host, domain, originAttributes) { + this._removeCookies(host, { domain, originAttributes }); + }, + + addCookieObservers() { + Services.obs.addObserver(cookieHelpers, "cookie-changed", false); + return null; + }, + + removeCookieObservers() { + Services.obs.removeObserver(cookieHelpers, "cookie-changed", false); + return null; + }, + + observe(subject, topic, data) { + if (!subject) { + return; + } + + switch (topic) { + case "cookie-changed": + if (data === "batch-deleted") { + let cookiesNoInterface = subject.QueryInterface(Ci.nsIArray); + let cookies = []; + + for (let i = 0; i < cookiesNoInterface.length; i++) { + let cookie = cookiesNoInterface.queryElementAt(i, Ci.nsICookie2); + cookies.push(cookie); + } + cookieHelpers.onCookieChanged(cookies, topic, data); + + return; + } + + let cookie = subject.QueryInterface(Ci.nsICookie2); + cookieHelpers.onCookieChanged(cookie, topic, data); + break; + } + }, + + handleParentRequest(msg) { + switch (msg.json.method) { + case "onCookieChanged": + let [cookie, topic, data] = msg.data.args; + cookie = JSON.parse(cookie); + cookieHelpers.onCookieChanged(cookie, topic, data); + break; + } + }, + + handleChildRequest(msg) { + switch (msg.json.method) { + case "getCookiesFromHost": { + let host = msg.data.args[0]; + let originAttributes = msg.data.args[1]; + let cookies = cookieHelpers.getCookiesFromHost(host, originAttributes); + return JSON.stringify(cookies); + } + case "addCookieObservers": { + return cookieHelpers.addCookieObservers(); + } + case "removeCookieObservers": { + return cookieHelpers.removeCookieObservers(); + } + case "editCookie": { + let rowdata = msg.data.args[0]; + return cookieHelpers.editCookie(rowdata); + } + case "removeCookie": { + let host = msg.data.args[0]; + let name = msg.data.args[1]; + let originAttributes = msg.data.args[2]; + return cookieHelpers.removeCookie(host, name, originAttributes); + } + case "removeAllCookies": { + let host = msg.data.args[0]; + let domain = msg.data.args[1]; + let originAttributes = msg.data.args[2]; + return cookieHelpers.removeAllCookies(host, domain, originAttributes); + } + default: + console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); + throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); + } + }, +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcessForCookies = function ({ mm, prefix }) { + cookieHelpers.onCookieChanged = + callChildProcess.bind(null, "onCookieChanged"); + + // listen for director-script requests from the child process + setMessageManager(mm); + + function callChildProcess(methodName, ...args) { + if (methodName === "onCookieChanged") { + args[0] = JSON.stringify(args[0]); + } + + try { + mm.sendAsyncMessage("debug:storage-cookie-request-child", { + method: methodName, + args: args + }); + } catch (e) { + // We may receive a NS_ERROR_NOT_INITIALIZED if the target window has + // been closed. This can legitimately happen in between test runs. + } + } + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:storage-cookie-request-parent", + cookieHelpers.handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:storage-cookie-request-parent", + cookieHelpers.handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => { + // Although "disconnected-from-child" implies that the child is already + // disconnected this is not the case. The disconnection takes place after + // this method has finished. This gives us chance to clean up items within + // the parent process e.g. observers. + cookieHelpers.removeCookieObservers(); + setMessageManager(null); + } + }; +}; + +/** + * Helper method to create the overriden object required in + * StorageActors.createActor for Local Storage and Session Storage. + * This method exists as both Local Storage and Session Storage have almost + * identical actors. + */ +function getObjectForLocalOrSessionStorage(type) { + return { + getNamesForHost(host) { + let storage = this.hostVsStores.get(host); + return storage ? Object.keys(storage) : []; + }, + + getValuesForHost(host, name) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return []; + } + if (name) { + let value = storage ? storage.getItem(name) : null; + return [{ name, value }]; + } + if (!storage) { + return []; + } + return Object.keys(storage).map(key => ({ + name: key, + value: storage.getItem(key) + })); + }, + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + populateStoresForHost(host, window) { + try { + this.hostVsStores.set(host, window[type]); + } catch (ex) { + console.warn(`Failed to enumerate ${type} for host ${host}: ${ex}`); + } + }, + + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (let window of this.windows) { + this.populateStoresForHost(this.getHostName(window.location), window); + } + }, + + getFields: Task.async(function* () { + return [ + { name: "name", editable: 1}, + { name: "value", editable: 1} + ]; + }), + + /** + * Edit localStorage or sessionStorage fields. + * + * @param {Object} data + * See editCookie() for format details. + */ + editItem: Task.async(function* ({host, field, oldValue, items}) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + + if (field === "name") { + storage.removeItem(oldValue); + } + + storage.setItem(items.name, items.value); + }), + + removeItem: Task.async(function* (host, name) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.removeItem(name); + }), + + removeAll: Task.async(function* (host) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.clear(); + }), + + observe(subject, topic, data) { + if (topic != "dom-storage2-changed" || data != type) { + return null; + } + + let host = this.getSchemaAndHost(subject.url); + + if (!this.hostVsStores.has(host)) { + return null; + } + + let action = "changed"; + if (subject.key == null) { + return this.storageActor.update("cleared", type, [host]); + } else if (subject.oldValue == null) { + action = "added"; + } else if (subject.newValue == null) { + action = "deleted"; + } + let updateData = {}; + updateData[host] = [subject.key]; + return this.storageActor.update(action, type, updateData); + }, + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + let uri = Services.io.newURI(url, null, null); + if (!uri.host) { + return uri.spec; + } + return uri.scheme + "://" + uri.hostPort; + }, + + toStoreObject(item) { + if (!item) { + return null; + } + + return { + name: item.name, + value: new LongStringActor(this.conn, item.value || "") + }; + }, + }; +} + +/** + * The Local Storage actor and front. + */ +StorageActors.createActor({ + typeName: "localStorage", + observationTopic: "dom-storage2-changed" +}, getObjectForLocalOrSessionStorage("localStorage")); + +/** + * The Session Storage actor and front. + */ +StorageActors.createActor({ + typeName: "sessionStorage", + observationTopic: "dom-storage2-changed" +}, getObjectForLocalOrSessionStorage("sessionStorage")); + +StorageActors.createActor({ + typeName: "Cache" +}, { + getCachesForHost: Task.async(function* (host) { + let uri = Services.io.newURI(host, null, null); + let principal = + Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri); + + // The first argument tells if you want to get |content| cache or |chrome| + // cache. + // The |content| cache is the cache explicitely named by the web content + // (service worker or web page). + // The |chrome| cache is the cache implicitely cached by the platform, + // hosting the source file of the service worker. + let { CacheStorage } = this.storageActor.window; + let cache = new CacheStorage("content", principal); + return cache; + }), + + preListStores: Task.async(function* () { + for (let host of this.hosts) { + yield this.populateStoresForHost(host); + } + }), + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + getNamesForHost(host) { + // UI code expect each name to be a JSON string of an array :/ + return [...this.hostVsStores.get(host).keys()].map(a => { + return JSON.stringify([a]); + }); + }, + + getValuesForHost: Task.async(function* (host, name) { + if (!name) { + return []; + } + // UI is weird and expect a JSON stringified array... and pass it back :/ + name = JSON.parse(name)[0]; + + let cache = this.hostVsStores.get(host).get(name); + let requests = yield cache.keys(); + let results = []; + for (let request of requests) { + let response = yield cache.match(request); + // Unwrap the response to get access to all its properties if the + // response happen to be 'opaque', when it is a Cross Origin Request. + response = response.cloneUnfiltered(); + results.push(yield this.processEntry(request, response)); + } + return results; + }), + + processEntry: Task.async(function* (request, response) { + return { + url: String(request.url), + status: String(response.statusText), + }; + }), + + getFields: Task.async(function* () { + return [ + { name: "url", editable: 0 }, + { name: "status", editable: 0 } + ]; + }), + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + populateStoresForHost: Task.async(function* (host) { + let storeMap = new Map(); + let caches = yield this.getCachesForHost(host); + try { + for (let name of (yield caches.keys())) { + storeMap.set(name, (yield caches.open(name))); + } + } catch (ex) { + console.warn(`Failed to enumerate CacheStorage for host ${host}: ${ex}`); + } + this.hostVsStores.set(host, storeMap); + }), + + /** + * This method is overriden and left blank as for Cache Storage, this + * operation cannot be performed synchronously. Thus, the preListStores + * method exists to do the same task asynchronously. + */ + populateStoresForHosts() { + this.hostVsStores = new Map(); + }, + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + let uri = Services.io.newURI(url, null, null); + return uri.scheme + "://" + uri.hostPort; + }, + + toStoreObject(item) { + return item; + }, + + removeItem: Task.async(function* (host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + if (parsedName.length == 1) { + // Delete the whole Cache object + const [ cacheName ] = parsedName; + cacheMap.delete(cacheName); + const cacheStorage = yield this.getCachesForHost(host); + yield cacheStorage.delete(cacheName); + this.onItemUpdated("deleted", host, [ cacheName ]); + } else if (parsedName.length == 2) { + // Delete one cached request + const [ cacheName, url ] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + yield cache.delete(url); + this.onItemUpdated("deleted", host, [ cacheName, url ]); + } + } + }), + + removeAll: Task.async(function* (host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + // Only a Cache object is a valid object to clear + if (parsedName.length == 1) { + const [ cacheName ] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + let keys = yield cache.keys(); + yield promise.all(keys.map(key => cache.delete(key))); + this.onItemUpdated("cleared", host, [ cacheName ]); + } + } + }), + + /** + * CacheStorage API doesn't support any notifications, we must fake them + */ + onItemUpdated(action, host, path) { + this.storageActor.update(action, "Cache", { + [host]: [ JSON.stringify(path) ] + }); + }, +}); + +/** + * Code related to the Indexed DB actor and front + */ + +// Metadata holder objects for various components of Indexed DB + +/** + * Meta data object for a particular index in an object store + * + * @param {IDBIndex} index + * The particular index from the object store. + */ +function IndexMetadata(index) { + this._name = index.name; + this._keyPath = index.keyPath; + this._unique = index.unique; + this._multiEntry = index.multiEntry; +} +IndexMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + unique: this._unique, + multiEntry: this._multiEntry + }; + } +}; + +/** + * Meta data object for a particular object store in a db + * + * @param {IDBObjectStore} objectStore + * The particular object store from the db. + */ +function ObjectStoreMetadata(objectStore) { + this._name = objectStore.name; + this._keyPath = objectStore.keyPath; + this._autoIncrement = objectStore.autoIncrement; + this._indexes = []; + + for (let i = 0; i < objectStore.indexNames.length; i++) { + let index = objectStore.index(objectStore.indexNames[i]); + + let newIndex = { + keypath: index.keyPath, + multiEntry: index.multiEntry, + name: index.name, + objectStore: { + autoIncrement: index.objectStore.autoIncrement, + indexNames: [...index.objectStore.indexNames], + keyPath: index.objectStore.keyPath, + name: index.objectStore.name, + } + }; + + this._indexes.push([newIndex, new IndexMetadata(index)]); + } +} +ObjectStoreMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + autoIncrement: this._autoIncrement, + indexes: JSON.stringify( + [...this._indexes.values()].map(index => index.toObject()) + ) + }; + } +}; + +/** + * Meta data object for a particular indexed db in a host. + * + * @param {string} origin + * The host associated with this indexed db. + * @param {IDBDatabase} db + * The particular indexed db. + */ +function DatabaseMetadata(origin, db) { + this._origin = origin; + this._name = db.name; + this._version = db.version; + this._objectStores = []; + + if (db.objectStoreNames.length) { + let transaction = db.transaction(db.objectStoreNames, "readonly"); + + for (let i = 0; i < transaction.objectStoreNames.length; i++) { + let objectStore = + transaction.objectStore(transaction.objectStoreNames[i]); + this._objectStores.push([transaction.objectStoreNames[i], + new ObjectStoreMetadata(objectStore)]); + } + } +} +DatabaseMetadata.prototype = { + get objectStores() { + return this._objectStores; + }, + + toObject() { + return { + name: this._name, + origin: this._origin, + version: this._version, + objectStores: this._objectStores.size + }; + } +}; + +StorageActors.createActor({ + typeName: "indexedDB" +}, { + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.maybeSetupChildProcess(); + + this.objectsSize = {}; + this.storageActor = storageActor; + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + this.hostVsStores.clear(); + this.objectsSize = null; + + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + /** + * Remove an indexedDB database from given host with a given name. + */ + removeDatabase: Task.async(function* (host, name) { + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return { error: `Window for host ${host} not found` }; + } + + let principal = win.document.nodePrincipal; + return this.removeDB(host, principal, name); + }), + + removeAll: Task.async(function* (host, name) { + let [db, store] = JSON.parse(name); + + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + let principal = win.document.nodePrincipal; + this.clearDBStore(host, principal, db, store); + }), + + removeItem: Task.async(function* (host, name) { + let [db, store, id] = JSON.parse(name); + + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + let principal = win.document.nodePrincipal; + this.removeDBRecord(host, principal, db, store, id); + }), + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + /** + * This method is overriden and left blank as for indexedDB, this operation + * cannot be performed synchronously. Thus, the preListStores method exists to + * do the same task asynchronously. + */ + populateStoresForHosts() {}, + + getNamesForHost(host) { + let names = []; + + for (let [dbName, {objectStores}] of this.hostVsStores.get(host)) { + if (objectStores.size) { + for (let objectStore of objectStores.keys()) { + names.push(JSON.stringify([dbName, objectStore])); + } + } else { + names.push(JSON.stringify([dbName])); + } + } + return names; + }, + + /** + * Returns the total number of entries for various types of requests to + * getStoreObjects for Indexed DB actor. + * + * @param {string} host + * The host for the request. + * @param {array:string} names + * Array of stringified name objects for indexed db actor. + * The request type depends on the length of any parsed entry from this + * array. 0 length refers to request for the whole host. 1 length + * refers to request for a particular db in the host. 2 length refers + * to a particular object store in a db in a host. 3 length refers to + * particular items of an object store in a db in a host. + * @param {object} options + * An options object containing following properties: + * - index {string} The IDBIndex for the object store in the db. + */ + getObjectsSize(host, names, options) { + // In Indexed DB, we are interested in only the first name, as the pattern + // should follow in all entries. + let name = names[0]; + let parsedName = JSON.parse(name); + + if (parsedName.length == 3) { + // This is the case where specific entries from an object store were + // requested + return names.length; + } else if (parsedName.length == 2) { + // This is the case where all entries from an object store are requested. + let index = options.index; + let [db, objectStore] = parsedName; + if (this.objectsSize[host + db + objectStore + index]) { + return this.objectsSize[host + db + objectStore + index]; + } + } else if (parsedName.length == 1) { + // This is the case where details of all object stores in a db are + // requested. + if (this.hostVsStores.has(host) && + this.hostVsStores.get(host).has(parsedName[0])) { + return this.hostVsStores.get(host).get(parsedName[0]).objectStores.size; + } + } else if (!parsedName || !parsedName.length) { + // This is the case were details of all dbs in a host are requested. + if (this.hostVsStores.has(host)) { + return this.hostVsStores.get(host).size; + } + } + return 0; + }, + + /** + * Purpose of this method is same as populateStoresForHosts but this is async. + * This exact same operation cannot be performed in populateStoresForHosts + * method, as that method is called in initialize method of the actor, which + * cannot be asynchronous. + */ + preListStores: Task.async(function* () { + this.hostVsStores = new Map(); + + for (let host of this.hosts) { + yield this.populateStoresForHost(host); + } + }), + + populateStoresForHost: Task.async(function* (host) { + let storeMap = new Map(); + let {names} = yield this.getDBNamesForHost(host); + let win = this.storageActor.getWindowFromHost(host); + if (win) { + let principal = win.document.nodePrincipal; + + for (let name of names) { + let metadata = yield this.getDBMetaData(host, principal, name); + + metadata = indexedDBHelpers.patchMetadataMapsAndProtos(metadata); + storeMap.set(name, metadata); + } + } + + this.hostVsStores.set(host, storeMap); + }), + + /** + * Returns the over-the-wire implementation of the indexed db entity. + */ + toStoreObject(item) { + if (!item) { + return null; + } + + if ("indexes" in item) { + // Object store meta data + return { + objectStore: item.name, + keyPath: item.keyPath, + autoIncrement: item.autoIncrement, + indexes: item.indexes + }; + } + if ("objectStores" in item) { + // DB meta data + return { + db: item.name, + origin: item.origin, + version: item.version, + objectStores: item.objectStores + }; + } + // Indexed db entry + return { + name: item.name, + value: new LongStringActor(this.conn, JSON.stringify(item.value)) + }; + }, + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + onItemUpdated(action, host, path) { + // Database was removed, remove it from stores map + if (action === "deleted" && path.length === 1) { + if (this.hostVsStores.has(host)) { + this.hostVsStores.get(host).delete(path[0]); + } + } + + this.storageActor.update(action, "indexedDB", { + [host]: [ JSON.stringify(path) ] + }); + }, + + maybeSetupChildProcess() { + if (!DebuggerServer.isInChildProcess) { + this.backToChild = (func, rv) => rv; + this.getDBMetaData = indexedDBHelpers.getDBMetaData; + this.openWithPrincipal = indexedDBHelpers.openWithPrincipal; + this.getDBNamesForHost = indexedDBHelpers.getDBNamesForHost; + this.getSanitizedHost = indexedDBHelpers.getSanitizedHost; + this.getNameFromDatabaseFile = indexedDBHelpers.getNameFromDatabaseFile; + this.getValuesForHost = indexedDBHelpers.getValuesForHost; + this.getObjectStoreData = indexedDBHelpers.getObjectStoreData; + this.removeDB = indexedDBHelpers.removeDB; + this.removeDBRecord = indexedDBHelpers.removeDBRecord; + this.clearDBStore = indexedDBHelpers.clearDBStore; + return; + } + + const { sendAsyncMessage, addMessageListener } = + this.conn.parentMessageManager; + + this.conn.setupInParent({ + module: "devtools/server/actors/storage", + setupParent: "setupParentProcessForIndexedDB" + }); + + this.getDBMetaData = callParentProcessAsync.bind(null, "getDBMetaData"); + this.getDBNamesForHost = callParentProcessAsync.bind(null, "getDBNamesForHost"); + this.getValuesForHost = callParentProcessAsync.bind(null, "getValuesForHost"); + this.removeDB = callParentProcessAsync.bind(null, "removeDB"); + this.removeDBRecord = callParentProcessAsync.bind(null, "removeDBRecord"); + this.clearDBStore = callParentProcessAsync.bind(null, "clearDBStore"); + + addMessageListener("debug:storage-indexedDB-request-child", msg => { + switch (msg.json.method) { + case "backToChild": { + let [func, rv] = msg.json.args; + let deferred = unresolvedPromises.get(func); + if (deferred) { + unresolvedPromises.delete(func); + deferred.resolve(rv); + } + break; + } + case "onItemUpdated": { + let [action, host, path] = msg.json.args; + this.onItemUpdated(action, host, path); + } + } + }); + + let unresolvedPromises = new Map(); + function callParentProcessAsync(methodName, ...args) { + let deferred = promise.defer(); + + unresolvedPromises.set(methodName, deferred); + + sendAsyncMessage("debug:storage-indexedDB-request-parent", { + method: methodName, + args: args + }); + + return deferred.promise; + } + }, + + getFields: Task.async(function* (subType) { + switch (subType) { + // Detail of database + case "database": + return [ + { name: "objectStore", editable: 0 }, + { name: "keyPath", editable: 0 }, + { name: "autoIncrement", editable: 0 }, + { name: "indexes", editable: 0 }, + ]; + + // Detail of object store + case "object store": + return [ + { name: "name", editable: 0 }, + { name: "value", editable: 0 } + ]; + + // Detail of indexedDB for one origin + default: + return [ + { name: "db", editable: 0 }, + { name: "origin", editable: 0 }, + { name: "version", editable: 0 }, + { name: "objectStores", editable: 0 }, + ]; + } + }) +}); + +var indexedDBHelpers = { + backToChild(...args) { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + + mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", { + method: "backToChild", + args: args + }); + }, + + onItemUpdated(action, host, path) { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + + mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", { + method: "onItemUpdated", + args: [ action, host, path ] + }); + }, + + /** + * Fetches and stores all the metadata information for the given database + * `name` for the given `host` with its `principal`. The stored metadata + * information is of `DatabaseMetadata` type. + */ + getDBMetaData: Task.async(function* (host, principal, name) { + let request = this.openWithPrincipal(principal, name); + let success = promise.defer(); + + request.onsuccess = event => { + let db = event.target.result; + + let dbData = new DatabaseMetadata(host, db); + db.close(); + + success.resolve(this.backToChild("getDBMetaData", dbData)); + }; + request.onerror = ({target}) => { + console.error( + `Error opening indexeddb database ${name} for host ${host}`, target.error); + success.resolve(this.backToChild("getDBMetaData", null)); + }; + return success.promise; + }), + + /** + * Opens an indexed db connection for the given `principal` and + * database `name`. + */ + openWithPrincipal(principal, name) { + return indexedDBForStorage.openForPrincipal(principal, name); + }, + + removeDB: Task.async(function* (host, principal, name) { + let result = new promise(resolve => { + let request = indexedDBForStorage.deleteForPrincipal(principal, name); + + request.onsuccess = () => { + resolve({}); + this.onItemUpdated("deleted", host, [name]); + }; + + request.onblocked = () => { + console.warn(`Deleting indexedDB database ${name} for host ${host} is blocked`); + resolve({ blocked: true }); + }; + + request.onerror = () => { + let { error } = request; + console.warn( + `Error deleting indexedDB database ${name} for host ${host}: ${error}`); + resolve({ error: error.message }); + }; + + // If the database is blocked repeatedly, the onblocked event will not + // be fired again. To avoid waiting forever, report as blocked if nothing + // else happens after 3 seconds. + setTimeout(() => resolve({ blocked: true }), 3000); + }); + + return this.backToChild("removeDB", yield result); + }), + + removeDBRecord: Task.async(function* (host, principal, dbName, storeName, id) { + let db; + + try { + db = yield new promise((resolve, reject) => { + let request = this.openWithPrincipal(principal, dbName); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + let transaction = db.transaction(storeName, "readwrite"); + let store = transaction.objectStore(storeName); + + yield new promise((resolve, reject) => { + let request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("deleted", host, [dbName, storeName, id]); + } catch (error) { + let recordPath = [dbName, storeName, id].join("/"); + console.error(`Failed to delete indexedDB record: ${recordPath}: ${error}`); + } + + if (db) { + db.close(); + } + + return this.backToChild("removeDBRecord", null); + }), + + clearDBStore: Task.async(function* (host, principal, dbName, storeName) { + let db; + + try { + db = yield new promise((resolve, reject) => { + let request = this.openWithPrincipal(principal, dbName); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + let transaction = db.transaction(storeName, "readwrite"); + let store = transaction.objectStore(storeName); + + yield new promise((resolve, reject) => { + let request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("cleared", host, [dbName, storeName]); + } catch (error) { + let storePath = [dbName, storeName].join("/"); + console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`); + } + + if (db) { + db.close(); + } + + return this.backToChild("clearDBStore", null); + }), + + /** + * Fetches all the databases and their metadata for the given `host`. + */ + getDBNamesForHost: Task.async(function* (host) { + let sanitizedHost = this.getSanitizedHost(host); + let directory = OS.Path.join(OS.Constants.Path.profileDir, "storage", + "default", sanitizedHost, "idb"); + + let exists = yield OS.File.exists(directory); + if (!exists && host.startsWith("about:")) { + // try for moz-safe-about directory + sanitizedHost = this.getSanitizedHost("moz-safe-" + host); + directory = OS.Path.join(OS.Constants.Path.profileDir, "storage", + "permanent", sanitizedHost, "idb"); + exists = yield OS.File.exists(directory); + } + if (!exists) { + return this.backToChild("getDBNamesForHost", {names: []}); + } + + let names = []; + let dirIterator = new OS.File.DirectoryIterator(directory); + try { + yield dirIterator.forEach(file => { + // Skip directories. + if (file.isDir) { + return null; + } + + // Skip any non-sqlite files. + if (!file.name.endsWith(".sqlite")) { + return null; + } + + return this.getNameFromDatabaseFile(file.path).then(name => { + if (name) { + names.push(name); + } + return null; + }); + }); + } finally { + dirIterator.close(); + } + return this.backToChild("getDBNamesForHost", {names: names}); + }), + + /** + * Removes any illegal characters from the host name to make it a valid file + * name. + */ + getSanitizedHost(host) { + return host.replace(ILLEGAL_CHAR_REGEX, "+"); + }, + + /** + * Retrieves the proper indexed db database name from the provided .sqlite + * file location. + */ + getNameFromDatabaseFile: Task.async(function* (path) { + let connection = null; + let retryCount = 0; + + // Content pages might be having an open transaction for the same indexed db + // which this sqlite file belongs to. In that case, sqlite.openConnection + // will throw. Thus we retey for some time to see if lock is removed. + while (!connection && retryCount++ < 25) { + try { + connection = yield Sqlite.openConnection({ path: path }); + } catch (ex) { + // Continuously retrying is overkill. Waiting for 100ms before next try + yield sleep(100); + } + } + + if (!connection) { + return null; + } + + let rows = yield connection.execute("SELECT name FROM database"); + if (rows.length != 1) { + return null; + } + + let name = rows[0].getResultByName("name"); + + yield connection.close(); + + return name; + }), + + getValuesForHost: Task.async(function* (host, name = "null", options, + hostVsStores, principal) { + name = JSON.parse(name); + if (!name || !name.length) { + // This means that details about the db in this particular host are + // requested. + let dbs = []; + if (hostVsStores.has(host)) { + for (let [, db] of hostVsStores.get(host)) { + db = indexedDBHelpers.patchMetadataMapsAndProtos(db); + dbs.push(db.toObject()); + } + } + return this.backToChild("getValuesForHost", {dbs: dbs}); + } + + let [db2, objectStore, id] = name; + if (!objectStore) { + // This means that details about all the object stores in this db are + // requested. + let objectStores = []; + if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) { + let db = hostVsStores.get(host).get(db2); + + db = indexedDBHelpers.patchMetadataMapsAndProtos(db); + + let objectStores2 = db.objectStores; + + for (let objectStore2 of objectStores2) { + objectStores.push(objectStore2[1].toObject()); + } + } + return this.backToChild("getValuesForHost", {objectStores: objectStores}); + } + // Get either all entries from the object store, or a particular id + let result = yield this.getObjectStoreData(host, principal, db2, + objectStore, id, options.index, options.size); + return this.backToChild("getValuesForHost", {result: result}); + }), + + /** + * Returns all or requested entries from a particular objectStore from the db + * in the given host. + * + * @param {string} host + * The given host. + * @param {nsIPrincipal} principal + * The principal of the given document. + * @param {string} dbName + * The name of the indexed db from the above host. + * @param {string} objectStore + * The name of the object store from the above db. + * @param {string} id + * id of the requested entry from the above object store. + * null if all entries from the above object store are requested. + * @param {string} index + * name of the IDBIndex to be iterated on while fetching entries. + * null or "name" if no index is to be iterated. + * @param {number} offset + * ofsset of the entries to be fetched. + * @param {number} size + * The intended size of the entries to be fetched. + */ + getObjectStoreData(host, principal, dbName, objectStore, id, index, + offset, size) { + let request = this.openWithPrincipal(principal, dbName); + let success = promise.defer(); + let data = []; + let db; + + if (!size || size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + + request.onsuccess = event => { + db = event.target.result; + + let transaction = db.transaction(objectStore, "readonly"); + let source = transaction.objectStore(objectStore); + if (index && index != "name") { + source = source.index(index); + } + + source.count().onsuccess = event2 => { + let objectsSize = []; + let count = event2.target.result; + objectsSize.push({ + key: host + dbName + objectStore + index, + count: count + }); + + if (!offset) { + offset = 0; + } else if (offset > count) { + db.close(); + success.resolve([]); + return; + } + + if (id) { + source.get(id).onsuccess = event3 => { + db.close(); + success.resolve([{name: id, value: event3.target.result}]); + }; + } else { + source.openCursor().onsuccess = event4 => { + let cursor = event4.target.result; + + if (!cursor || data.length >= size) { + db.close(); + success.resolve({ + data: data, + objectsSize: objectsSize + }); + return; + } + if (offset-- <= 0) { + data.push({name: cursor.key, value: cursor.value}); + } + cursor.continue(); + }; + } + }; + }; + request.onerror = () => { + db.close(); + success.resolve([]); + }; + return success.promise; + }, + + /** + * When indexedDB metadata is parsed to and from JSON then the object's + * prototype is dropped and any Maps are changed to arrays of arrays. This + * method is used to repair the prototypes and fix any broken Maps. + */ + patchMetadataMapsAndProtos(metadata) { + let md = Object.create(DatabaseMetadata.prototype); + Object.assign(md, metadata); + + md._objectStores = new Map(metadata._objectStores); + + for (let [name, store] of md._objectStores) { + let obj = Object.create(ObjectStoreMetadata.prototype); + Object.assign(obj, store); + + md._objectStores.set(name, obj); + + if (typeof store._indexes.length !== "undefined") { + obj._indexes = new Map(store._indexes); + } + + for (let [name2, value] of obj._indexes) { + let obj2 = Object.create(IndexMetadata.prototype); + Object.assign(obj2, value); + + obj._indexes.set(name2, obj2); + } + } + + return md; + }, + + handleChildRequest(msg) { + let args = msg.data.args; + + switch (msg.json.method) { + case "getDBMetaData": { + let [host, principal, name] = args; + return indexedDBHelpers.getDBMetaData(host, principal, name); + } + case "getDBNamesForHost": { + let [host] = args; + return indexedDBHelpers.getDBNamesForHost(host); + } + case "getValuesForHost": { + let [host, name, options, hostVsStores, principal] = args; + return indexedDBHelpers.getValuesForHost(host, name, options, + hostVsStores, principal); + } + case "removeDB": { + let [host, principal, name] = args; + return indexedDBHelpers.removeDB(host, principal, name); + } + case "removeDBRecord": { + let [host, principal, db, store, id] = args; + return indexedDBHelpers.removeDBRecord(host, principal, db, store, id); + } + case "clearDBStore": { + let [host, principal, db, store] = args; + return indexedDBHelpers.clearDBStore(host, principal, db, store); + } + default: + console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); + throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); + } + } +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcessForIndexedDB = function ({ mm, prefix }) { + // listen for director-script requests from the child process + setMessageManager(mm); + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:storage-indexedDB-request-parent", + indexedDBHelpers.handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:storage-indexedDB-request-parent", + indexedDBHelpers.handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => setMessageManager(null), + }; +}; + +/** + * The main Storage Actor. + */ +let StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, { + typeName: "storage", + + get window() { + return this.parentActor.window; + }, + + get document() { + return this.parentActor.window.document; + }, + + get windows() { + return this.childWindowPool; + }, + + initialize(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.conn = conn; + this.parentActor = tabActor; + + this.childActorPool = new Map(); + this.childWindowPool = new Set(); + + // Fetch all the inner iframe windows in this tab. + this.fetchChildWindows(this.parentActor.docShell); + + // Initialize the registered store types + for (let [store, ActorConstructor] of storageTypePool) { + this.childActorPool.set(store, new ActorConstructor(this)); + } + + // Notifications that help us keep track of newly added windows and windows + // that got removed + Services.obs.addObserver(this, "content-document-global-created", false); + Services.obs.addObserver(this, "inner-window-destroyed", false); + this.onPageChange = this.onPageChange.bind(this); + + let handler = tabActor.chromeEventHandler; + handler.addEventListener("pageshow", this.onPageChange, true); + handler.addEventListener("pagehide", this.onPageChange, true); + + this.destroyed = false; + this.boundUpdate = {}; + }, + + destroy() { + clearTimeout(this.batchTimer); + this.batchTimer = null; + // Remove observers + Services.obs.removeObserver(this, "content-document-global-created", false); + Services.obs.removeObserver(this, "inner-window-destroyed", false); + this.destroyed = true; + if (this.parentActor.browser) { + this.parentActor.browser.removeEventListener( + "pageshow", this.onPageChange, true); + this.parentActor.browser.removeEventListener( + "pagehide", this.onPageChange, true); + } + // Destroy the registered store types + for (let actor of this.childActorPool.values()) { + actor.destroy(); + } + this.childActorPool.clear(); + this.childWindowPool.clear(); + this.childWindowPool = this.childActorPool = this.__poolMap = this.conn = + this.parentActor = this.boundUpdate = this.registeredPool = + this._pendingResponse = null; + }, + + /** + * Given a docshell, recursively find out all the child windows from it. + * + * @param {nsIDocShell} item + * The docshell from which all inner windows need to be extracted. + */ + fetchChildWindows(item) { + let docShell = item.QueryInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem); + if (!docShell.contentViewer) { + return null; + } + let window = docShell.contentViewer.DOMDocument.defaultView; + if (window.location.href == "about:blank") { + // Skip out about:blank windows as Gecko creates them multiple times while + // creating any global. + return null; + } + this.childWindowPool.add(window); + for (let i = 0; i < docShell.childCount; i++) { + let child = docShell.getChildAt(i); + this.fetchChildWindows(child); + } + return null; + }, + + isIncludedInTopLevelWindow(window) { + return isWindowIncluded(this.window, window); + }, + + getWindowFromInnerWindowID(innerID) { + innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data; + for (let win of this.childWindowPool.values()) { + let id = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; + if (id == innerID) { + return win; + } + } + return null; + }, + + getWindowFromHost(host) { + for (let win of this.childWindowPool.values()) { + let origin = win.document + .nodePrincipal + .originNoSuffix; + let url = win.document.URL; + if (origin === host || url === host) { + return win; + } + } + return null; + }, + + /** + * Event handler for any docshell update. This lets us figure out whenever + * any new window is added, or an existing window is removed. + */ + observe(subject, topic) { + if (subject.location && + (!subject.location.href || subject.location.href == "about:blank")) { + return null; + } + + if (topic == "content-document-global-created" && + this.isIncludedInTopLevelWindow(subject)) { + this.childWindowPool.add(subject); + events.emit(this, "window-ready", subject); + } else if (topic == "inner-window-destroyed") { + let window = this.getWindowFromInnerWindowID(subject); + if (window) { + this.childWindowPool.delete(window); + events.emit(this, "window-destroyed", window); + } + } + return null; + }, + + /** + * Called on "pageshow" or "pagehide" event on the chromeEventHandler of + * current tab. + * + * @param {event} The event object passed to the handler. We are using these + * three properties from the event: + * - target {document} The document corresponding to the event. + * - type {string} Name of the event - "pageshow" or "pagehide". + * - persisted {boolean} true if there was no + * "content-document-global-created" notification along + * this event. + */ + onPageChange({target, type, persisted}) { + if (this.destroyed) { + return; + } + + let window = target.defaultView; + + if (type == "pagehide" && this.childWindowPool.delete(window)) { + events.emit(this, "window-destroyed", window); + } else if (type == "pageshow" && persisted && window.location.href && + window.location.href != "about:blank" && + this.isIncludedInTopLevelWindow(window)) { + this.childWindowPool.add(window); + events.emit(this, "window-ready", window); + } + }, + + /** + * Lists the available hosts for all the registered storage types. + * + * @returns {object} An object containing with the following structure: + * - : [{ + * actor: , + * host: + * }] + */ + listStores: Task.async(function* () { + let toReturn = {}; + + for (let [name, value] of this.childActorPool) { + if (value.preListStores) { + yield value.preListStores(); + } + toReturn[name] = value; + } + + return toReturn; + }), + + /** + * This method is called by the registered storage types so as to tell the + * Storage Actor that there are some changes in the stores. Storage Actor then + * notifies the client front about these changes at regular (BATCH_DELAY) + * interval. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor in which this change has occurred. + * @param {object} data + * The update object. This object is of the following format: + * - { + * : [, ...], + * : [...], + * } + * Where host1, host2 are the host in which this change happened and + * [ { + clearTimeout(this.batchTimer); + events.emit(this, "stores-update", this.boundUpdate); + this.boundUpdate = {}; + }, BATCH_DELAY); + + return null; + }, + + /** + * This method removes data from the this.boundUpdate object in the same + * manner like this.update() adds data to it. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor for which you want to remove the updates data. + * @param {object} data + * The update object. This object is of the following format: + * - { + * : [, ...], + * : [...], + * } + * Where host1, host2 are the hosts which you want to remove and + * [ -1) { + this.boundUpdate[action][storeType][host].splice(index, 1); + } + } + if (!this.boundUpdate[action][storeType][host].length) { + delete this.boundUpdate[action][storeType][host]; + } + } + } + return null; + } +}); + +exports.StorageActor = StorageActor; -- cgit v1.2.3