diff options
Diffstat (limited to 'services/sync/tps/extensions/tps/resource/modules')
10 files changed, 2915 insertions, 0 deletions
diff --git a/services/sync/tps/extensions/tps/resource/modules/addons.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/addons.sys.mjs new file mode 100644 index 0000000000..596f942a06 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/addons.sys.mjs @@ -0,0 +1,93 @@ +/* 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/. */ + +import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs"; +import { AddonUtils } from "resource://services-sync/addonutils.sys.mjs"; +import { Logger } from "resource://tps/logger.sys.mjs"; + +export const STATE_ENABLED = 1; +export const STATE_DISABLED = 2; + +export function Addon(TPS, id) { + this.TPS = TPS; + this.id = id; +} + +Addon.prototype = { + addon: null, + + async uninstall() { + // find our addon locally + let addon = await AddonManager.getAddonByID(this.id); + Logger.AssertTrue( + !!addon, + "could not find addon " + this.id + " to uninstall" + ); + await AddonUtils.uninstallAddon(addon); + }, + + async find(state) { + let addon = await AddonManager.getAddonByID(this.id); + + if (!addon) { + Logger.logInfo("Could not find add-on with ID: " + this.id); + return false; + } + + this.addon = addon; + + Logger.logInfo( + "add-on found: " + addon.id + ", enabled: " + !addon.userDisabled + ); + if (state == STATE_ENABLED) { + Logger.AssertFalse(addon.userDisabled, "add-on is disabled: " + addon.id); + return true; + } else if (state == STATE_DISABLED) { + Logger.AssertTrue(addon.userDisabled, "add-on is enabled: " + addon.id); + return true; + } else if (state) { + throw new Error("Don't know how to handle state: " + state); + } else { + // No state, so just checking that it exists. + return true; + } + }, + + async install() { + // For Install, the id parameter initially passed is really the filename + // for the addon's install .xml; we'll read the actual id from the .xml. + + const result = await AddonUtils.installAddons([ + { id: this.id, requireSecureURI: false }, + ]); + + Logger.AssertEqual( + 1, + result.installedIDs.length, + "Exactly 1 add-on was installed." + ); + Logger.AssertEqual( + this.id, + result.installedIDs[0], + "Add-on was installed successfully: " + this.id + ); + }, + + async setEnabled(flag) { + Logger.AssertTrue(await this.find(), "Add-on is available."); + + let userDisabled; + if (flag == STATE_ENABLED) { + userDisabled = false; + } else if (flag == STATE_DISABLED) { + userDisabled = true; + } else { + throw new Error("Unknown flag to setEnabled: " + flag); + } + + AddonUtils.updateUserDisabled(this.addon, userDisabled); + + return true; + }, +}; diff --git a/services/sync/tps/extensions/tps/resource/modules/bookmarkValidator.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/bookmarkValidator.sys.mjs new file mode 100644 index 0000000000..e075b55149 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/bookmarkValidator.sys.mjs @@ -0,0 +1,1065 @@ +/* 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/. */ + +// This file was moved to tps from the main production code as it was unused +// after removal of the non-mirror bookmarks engine. +// It used to have a test before it was moved: +// https://searchfox.org/mozilla-central/rev/b1a5802e0f73bfd6d2096e5fefc2b47831a50b2d/services/sync/tests/unit/test_bookmark_validator.js + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { CommonUtils } from "resource://services-common/utils.sys.mjs"; +import { Utils } from "resource://services-sync/util.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Async: "resource://services-common/async.sys.mjs", + PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const QUERY_PROTOCOL = "place:"; + +function areURLsEqual(a, b) { + if (a === b) { + return true; + } + if (a.startsWith(QUERY_PROTOCOL) != b.startsWith(QUERY_PROTOCOL)) { + return false; + } + // Tag queries are special because we rewrite them to point to the + // local tag folder ID. It's expected that the folders won't match, + // but all other params should. + let aParams = new URLSearchParams(a.slice(QUERY_PROTOCOL.length)); + let aType = +aParams.get("type"); + if (aType != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) { + return false; + } + let bParams = new URLSearchParams(b.slice(QUERY_PROTOCOL.length)); + let bType = +bParams.get("type"); + if (bType != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) { + return false; + } + let aKeys = new Set(aParams.keys()); + let bKeys = new Set(bParams.keys()); + if (aKeys.size != bKeys.size) { + return false; + } + // Tag queries shouldn't reference multiple folders, or named folders like + // "TOOLBAR" or "BOOKMARKS_MENU". Just in case, we make sure all folder IDs + // are numeric. If they are, we ignore them when comparing the query params. + if (aKeys.has("folder") && aParams.getAll("folder").every(isFinite)) { + aKeys.delete("folder"); + } + if (bKeys.has("folder") && bParams.getAll("folder").every(isFinite)) { + bKeys.delete("folder"); + } + for (let key of aKeys) { + if (!bKeys.has(key)) { + return false; + } + if ( + !CommonUtils.arrayEqual( + aParams.getAll(key).sort(), + bParams.getAll(key).sort() + ) + ) { + return false; + } + } + for (let key of bKeys) { + if (!aKeys.has(key)) { + return false; + } + } + return true; +} + +const BOOKMARK_VALIDATOR_VERSION = 1; + +/** + * Result of bookmark validation. Contains the following fields which describe + * server-side problems unless otherwise specified. + * + * - missingIDs (number): # of objects with missing ids + * - duplicates (array of ids): ids seen more than once + * - parentChildMismatches (array of {parent: parentid, child: childid}): + * instances where the child's parentid and the parent's children array + * do not match + * - cycles (array of array of ids). List of cycles found in the server-side tree. + * - clientCycles (array of array of ids). List of cycles found in the client-side tree. + * - orphans (array of {id: string, parent: string}): List of nodes with + * either no parentid, or where the parent could not be found. + * - missingChildren (array of {parent: id, child: id}): + * List of parent/children where the child id couldn't be found + * - deletedChildren (array of { parent: id, child: id }): + * List of parent/children where child id was a deleted item (but still showed up + * in the children array) + * - multipleParents (array of {child: id, parents: array of ids}): + * List of children that were part of multiple parent arrays + * - deletedParents (array of ids) : List of records that aren't deleted but + * had deleted parents + * - childrenOnNonFolder (array of ids): list of non-folders that still have + * children arrays + * - duplicateChildren (array of ids): list of records who have the same + * child listed multiple times in their children array + * - parentNotFolder (array of ids): list of records that have parents that + * aren't folders + * - rootOnServer (boolean): true if the root came from the server + * - badClientRoots (array of ids): Contains any client-side root ids where + * the root is missing or isn't a (direct) child of the places root. + * + * - clientMissing: Array of ids on the server missing from the client + * - serverMissing: Array of ids on the client missing from the server + * - serverDeleted: Array of ids on the client that the server had marked as deleted. + * - serverUnexpected: Array of ids that appear on the server but shouldn't + * because the client attempts to never upload them. + * - differences: Array of {id: string, differences: string array} recording + * the non-structural properties that are differente between the client and server + * - structuralDifferences: As above, but contains the items where the differences were + * structural, that is, they contained childGUIDs or parentid + */ +export class BookmarkProblemData { + constructor() { + this.rootOnServer = false; + this.missingIDs = 0; + + this.duplicates = []; + this.parentChildMismatches = []; + this.cycles = []; + this.clientCycles = []; + this.orphans = []; + this.missingChildren = []; + this.deletedChildren = []; + this.multipleParents = []; + this.deletedParents = []; + this.childrenOnNonFolder = []; + this.duplicateChildren = []; + this.parentNotFolder = []; + + this.badClientRoots = []; + this.clientMissing = []; + this.serverMissing = []; + this.serverDeleted = []; + this.serverUnexpected = []; + this.differences = []; + this.structuralDifferences = []; + } + + /** + * Convert ("difference", [{ differences: ["tags", "name"] }, { differences: ["name"] }]) into + * [{ name: "difference:tags", count: 1}, { name: "difference:name", count: 2 }], etc. + */ + _summarizeDifferences(prefix, diffs) { + let diffCounts = new Map(); + for (let { differences } of diffs) { + for (let type of differences) { + let name = prefix + ":" + type; + let count = diffCounts.get(name) || 0; + diffCounts.set(name, count + 1); + } + } + return [...diffCounts].map(([name, count]) => ({ name, count })); + } + + /** + * Produce a list summarizing problems found. Each entry contains {name, count}, + * where name is the field name for the problem, and count is the number of times + * the problem was encountered. + * + * Validation has failed if all counts are not 0. + * + * If the `full` argument is truthy, we also include information about which + * properties we saw structural differences in. Currently, this means either + * "sdiff:parentid" and "sdiff:childGUIDS" may be present. + */ + getSummary(full) { + let result = [ + { name: "clientMissing", count: this.clientMissing.length }, + { name: "serverMissing", count: this.serverMissing.length }, + { name: "serverDeleted", count: this.serverDeleted.length }, + { name: "serverUnexpected", count: this.serverUnexpected.length }, + + { + name: "structuralDifferences", + count: this.structuralDifferences.length, + }, + { name: "differences", count: this.differences.length }, + + { name: "missingIDs", count: this.missingIDs }, + { name: "rootOnServer", count: this.rootOnServer ? 1 : 0 }, + + { name: "duplicates", count: this.duplicates.length }, + { + name: "parentChildMismatches", + count: this.parentChildMismatches.length, + }, + { name: "cycles", count: this.cycles.length }, + { name: "clientCycles", count: this.clientCycles.length }, + { name: "badClientRoots", count: this.badClientRoots.length }, + { name: "orphans", count: this.orphans.length }, + { name: "missingChildren", count: this.missingChildren.length }, + { name: "deletedChildren", count: this.deletedChildren.length }, + { name: "multipleParents", count: this.multipleParents.length }, + { name: "deletedParents", count: this.deletedParents.length }, + { name: "childrenOnNonFolder", count: this.childrenOnNonFolder.length }, + { name: "duplicateChildren", count: this.duplicateChildren.length }, + { name: "parentNotFolder", count: this.parentNotFolder.length }, + ]; + if (full) { + let structural = this._summarizeDifferences( + "sdiff", + this.structuralDifferences + ); + result.push.apply(result, structural); + } + return result; + } +} + +// Defined lazily to avoid initializing PlacesUtils.bookmarks too soon. +XPCOMUtils.defineLazyGetter(lazy, "SYNCED_ROOTS", () => [ + lazy.PlacesUtils.bookmarks.menuGuid, + lazy.PlacesUtils.bookmarks.toolbarGuid, + lazy.PlacesUtils.bookmarks.unfiledGuid, + lazy.PlacesUtils.bookmarks.mobileGuid, +]); + +// Maps root GUIDs to their query folder names from +// toolkit/components/places/nsNavHistoryQuery.cpp. We follow queries that +// reference existing folders in the client tree, and detect cycles where a +// query references its containing folder. +XPCOMUtils.defineLazyGetter(lazy, "ROOT_GUID_TO_QUERY_FOLDER_NAME", () => ({ + [lazy.PlacesUtils.bookmarks.rootGuid]: "PLACES_ROOT", + [lazy.PlacesUtils.bookmarks.menuGuid]: "BOOKMARKS_MENU", + + // Tags should never show up in our client tree, and never form cycles, but we + // report them just in case. + [lazy.PlacesUtils.bookmarks.tagsGuid]: "TAGS", + + [lazy.PlacesUtils.bookmarks.unfiledGuid]: "UNFILED_BOOKMARKS", + [lazy.PlacesUtils.bookmarks.toolbarGuid]: "TOOLBAR", + [lazy.PlacesUtils.bookmarks.mobileGuid]: "MOBILE_BOOKMARKS", +})); + +async function detectCycles(records) { + // currentPath and pathLookup contain the same data. pathLookup is faster to + // query, but currentPath gives is the order of traversal that we need in + // order to report the members of the cycles. + let pathLookup = new Set(); + let currentPath = []; + let cycles = []; + let seenEver = new Set(); + const yieldState = lazy.Async.yieldState(); + + const traverse = async node => { + if (pathLookup.has(node)) { + let cycleStart = currentPath.lastIndexOf(node); + let cyclePath = currentPath.slice(cycleStart).map(n => n.id); + cycles.push(cyclePath); + return; + } else if (seenEver.has(node)) { + // If we're checking the server, this is a problem, but it should already be reported. + // On the client, this could happen due to including `node.concrete` in the child list. + return; + } + seenEver.add(node); + let children = node.children || []; + if (node.concreteItems) { + children.push(...node.concreteItems); + } + if (children.length) { + pathLookup.add(node); + currentPath.push(node); + await lazy.Async.yieldingForEach(children, traverse, yieldState); + currentPath.pop(); + pathLookup.delete(node); + } + }; + + await lazy.Async.yieldingForEach( + records, + async record => { + if (!seenEver.has(record)) { + await traverse(record); + } + }, + yieldState + ); + + return cycles; +} + +class ServerRecordInspection { + constructor() { + this.serverRecords = null; + this.liveRecords = []; + + this.folders = []; + + this.root = null; + + this.idToRecord = new Map(); + + this.deletedIds = new Set(); + this.deletedRecords = []; + + this.problemData = new BookmarkProblemData(); + + // These are handled outside of problemData + this._orphans = new Map(); + this._multipleParents = new Map(); + + this.yieldState = lazy.Async.yieldState(); + } + + static async create(records) { + return new ServerRecordInspection().performInspection(records); + } + + async performInspection(records) { + await this._setRecords(records); + await this._linkParentIDs(); + await this._linkChildren(); + await this._findOrphans(); + await this._finish(); + return this; + } + + // We don't set orphans in this.problemData. Instead, we walk the tree at the + // end to find unreachable items. + _noteOrphan(id, parentId = undefined) { + // This probably shouldn't be called with a parentId twice, but if it + // happens we take the most recent one. + if (parentId || !this._orphans.has(id)) { + this._orphans.set(id, parentId); + } + } + + noteParent(child, parent) { + let parents = this._multipleParents.get(child); + if (!parents) { + this._multipleParents.set(child, [parent]); + } else { + parents.push(parent); + } + } + + noteMismatch(child, parent) { + let exists = this.problemData.parentChildMismatches.some( + match => match.child == child && match.parent == parent + ); + if (!exists) { + this.problemData.parentChildMismatches.push({ child, parent }); + } + } + + // - Populates `this.deletedIds`, `this.folders`, and `this.idToRecord` + // - calls `_initRoot` (thus initializing `this.root`). + async _setRecords(records) { + if (this.serverRecords) { + // In general this class is expected to be created, have + // `performInspection` called, and then only read from from that point on. + throw new Error("Bug: ServerRecordInspection can't `setRecords` twice"); + } + this.serverRecords = records; + let rootChildren = []; + + await lazy.Async.yieldingForEach( + this.serverRecords, + async record => { + if (!record.id) { + ++this.problemData.missingIDs; + return; + } + + if (record.deleted) { + this.deletedIds.add(record.id); + } + if (this.idToRecord.has(record.id)) { + this.problemData.duplicates.push(record.id); + return; + } + + this.idToRecord.set(record.id, record); + + if (!record.deleted) { + this.liveRecords.push(record); + + if (record.parentid == "places") { + rootChildren.push(record); + } + } + + if (!record.children) { + return; + } + + if (record.type != "folder") { + // Due to implementation details in engines/bookmarks.js, (Livemark + // subclassing BookmarkFolder) Livemarks will have a children array, + // but it should still be empty. + if (!record.children.length) { + return; + } + // Otherwise we mark it as an error and still try to resolve the children + this.problemData.childrenOnNonFolder.push(record.id); + } + + this.folders.push(record); + + if (new Set(record.children).size !== record.children.length) { + this.problemData.duplicateChildren.push(record.id); + } + + // After we're through with them, folder records store 3 (ugh) arrays that + // represent their folder information. The final fields looks like: + // + // - childGUIDs: The original `children` array, which is an array of + // record IDs. + // + // - unfilteredChildren: Contains more or less `childGUIDs.map(id => + // idToRecord.get(id))`, without the nulls for missing children. It will + // still have deleted, duplicate, mismatching, etc. children. + // + // - children: This is the 'cleaned' version of the child records that are + // safe to iterate over, etc.. If there are no reported problems, it should + // be identical to unfilteredChildren. + // + // The last two are left alone until later `this._linkChildren`, however. + record.childGUIDs = record.children; + + await lazy.Async.yieldingForEach( + record.childGUIDs, + id => { + this.noteParent(id, record.id); + }, + this.yieldState + ); + + record.children = []; + }, + this.yieldState + ); + + // Finish up some parts we can easily do now that we have idToRecord. + this.deletedRecords = Array.from(this.deletedIds, id => + this.idToRecord.get(id) + ); + + this._initRoot(rootChildren); + } + + _initRoot(rootChildren) { + let serverRoot = this.idToRecord.get("places"); + if (serverRoot) { + this.root = serverRoot; + this.problemData.rootOnServer = true; + return; + } + + // Fabricate a root. We want to be able to remember that it's fake, but + // would like to avoid it needing too much special casing, so we come up + // with children for it too (we just get these while we're iterating over + // the records to avoid needing two passes over a potentially large number + // of records). + + this.root = { + id: "places", + fake: true, + children: rootChildren, + childGUIDs: rootChildren.map(record => record.id), + type: "folder", + title: "", + }; + this.liveRecords.push(this.root); + this.idToRecord.set("places", this.root); + } + + // Adds `parent` to all records it can that have `parentid` + async _linkParentIDs() { + await lazy.Async.yieldingForEach( + this.idToRecord, + ([id, record]) => { + if (record == this.root || record.deleted) { + return false; + } + + // Check and update our orphan map. + let parentID = record.parentid; + let parent = this.idToRecord.get(parentID); + if (!parentID || !parent) { + this._noteOrphan(id, parentID); + return false; + } + + record.parent = parent; + + if (parent.deleted) { + this.problemData.deletedParents.push(id); + return true; + } else if (parent.type != "folder") { + this.problemData.parentNotFolder.push(record.id); + return true; + } + + if (parent.id !== "place" || this.problemData.rootOnServer) { + if (!parent.childGUIDs.includes(record.id)) { + this.noteMismatch(record.id, parent.id); + } + } + + if (parent.deleted && !record.deleted) { + this.problemData.deletedParents.push(record.id); + } + + // Note: We used to check if the parentName on the server matches the + // actual local parent name, but given this is used only for de-duping a + // record the first time it is seen and expensive to keep up-to-date, we + // decided to just stop recording it. See bug 1276969 for more. + return false; + }, + this.yieldState + ); + } + + // Build the children and unfilteredChildren arrays, (which are of record + // objects, not ids) + async _linkChildren() { + // Check that we aren't missing any children. + await lazy.Async.yieldingForEach( + this.folders, + async folder => { + folder.children = []; + folder.unfilteredChildren = []; + + let idsThisFolder = new Set(); + + await lazy.Async.yieldingForEach( + folder.childGUIDs, + childID => { + let child = this.idToRecord.get(childID); + + if (!child) { + this.problemData.missingChildren.push({ + parent: folder.id, + child: childID, + }); + return; + } + + if (child.deleted) { + this.problemData.deletedChildren.push({ + parent: folder.id, + child: childID, + }); + return; + } + + if (child.parentid != folder.id) { + this.noteMismatch(childID, folder.id); + return; + } + + if (idsThisFolder.has(childID)) { + // Already recorded earlier, we just don't want to mess up `children` + return; + } + folder.children.push(child); + }, + this.yieldState + ); + }, + this.yieldState + ); + } + + // Finds the orphans in the tree using something similar to a `mark and sweep` + // strategy. That is, we iterate over the children from the root, remembering + // which items we've seen. Then, we iterate all items, and know the ones we + // haven't seen are orphans. + async _findOrphans() { + let seen = new Set([this.root.id]); + + const inCycle = await lazy.Async.yieldingForEach( + Utils.walkTree(this.root), + ([node]) => { + if (seen.has(node.id)) { + // We're in an infloop due to a cycle. + // Return early to avoid reporting false positives for orphans. + return true; + } + seen.add(node.id); + + return false; + }, + this.yieldState + ); + + if (inCycle) { + return; + } + + await lazy.Async.yieldingForEach( + this.liveRecords, + (record, i) => { + if (!seen.has(record.id)) { + // We intentionally don't record the parentid here, since we only record + // that if the record refers to a parent that doesn't exist, which we + // have already handled (when linking parentid's). + this._noteOrphan(record.id); + } + }, + this.yieldState + ); + + await lazy.Async.yieldingForEach( + this._orphans, + ([id, parent]) => { + this.problemData.orphans.push({ id, parent }); + }, + this.yieldState + ); + } + + async _finish() { + this.problemData.cycles = await detectCycles(this.liveRecords); + + for (const [child, recordedParents] of this._multipleParents) { + let parents = new Set(recordedParents); + if (parents.size > 1) { + this.problemData.multipleParents.push({ child, parents: [...parents] }); + } + } + // Dedupe simple arrays in the problem data, so that we don't have to worry + // about it in the code + const idArrayProps = [ + "duplicates", + "deletedParents", + "childrenOnNonFolder", + "duplicateChildren", + "parentNotFolder", + ]; + for (let prop of idArrayProps) { + this.problemData[prop] = [...new Set(this.problemData[prop])]; + } + } +} + +export class BookmarkValidator { + constructor() { + this.yieldState = lazy.Async.yieldState(); + } + + async canValidate() { + return !(await lazy.PlacesSyncUtils.bookmarks.havePendingChanges()); + } + + async _followQueries(recordsByQueryId) { + await lazy.Async.yieldingForEach( + recordsByQueryId.values(), + entry => { + if ( + entry.type !== "query" && + (!entry.bmkUri || !entry.bmkUri.startsWith(QUERY_PROTOCOL)) + ) { + return; + } + let params = new URLSearchParams( + entry.bmkUri.slice(QUERY_PROTOCOL.length) + ); + // Queries with `excludeQueries` won't form cycles because they'll + // exclude all queries, including themselves, from the result set. + let excludeQueries = params.get("excludeQueries"); + if (excludeQueries === "1" || excludeQueries === "true") { + // `nsNavHistoryQuery::ParseQueryBooleanString` allows `1` and `true`. + return; + } + entry.concreteItems = []; + let queryIds = params.getAll("folder"); + for (let queryId of queryIds) { + let concreteItem = recordsByQueryId.get(queryId); + if (concreteItem) { + entry.concreteItems.push(concreteItem); + } + } + }, + this.yieldState + ); + } + + async createClientRecordsFromTree(clientTree) { + // Iterate over the treeNode, converting it to something more similar to what + // the server stores. + let records = []; + // A map of local IDs and well-known query folder names to records. Unlike + // GUIDs, local IDs aren't synced, since they're not stable across devices. + // New Places APIs use GUIDs to refer to bookmarks, but the legacy APIs + // still use local IDs. We use this mapping to parse `place:` queries that + // refer to folders via their local IDs. + let recordsByQueryId = new Map(); + let syncedRoots = lazy.SYNCED_ROOTS; + + const traverse = async (treeNode, synced) => { + if (!synced) { + synced = syncedRoots.includes(treeNode.guid); + } + let localId = treeNode.id; + let guid = lazy.PlacesSyncUtils.bookmarks.guidToRecordId(treeNode.guid); + let itemType = "item"; + treeNode.ignored = !synced; + treeNode.id = guid; + switch (treeNode.type) { + case lazy.PlacesUtils.TYPE_X_MOZ_PLACE: + if (treeNode.uri.startsWith(QUERY_PROTOCOL)) { + itemType = "query"; + } else { + itemType = "bookmark"; + } + break; + case lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: + let isLivemark = false; + if (treeNode.annos) { + for (let anno of treeNode.annos) { + if (anno.name === lazy.PlacesUtils.LMANNO_FEEDURI) { + isLivemark = true; + treeNode.feedUri = anno.value; + } else if (anno.name === lazy.PlacesUtils.LMANNO_SITEURI) { + isLivemark = true; + treeNode.siteUri = anno.value; + } + } + } + itemType = isLivemark ? "livemark" : "folder"; + break; + case lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: + itemType = "separator"; + break; + } + + if (treeNode.tags) { + treeNode.tags = treeNode.tags.split(","); + } else { + treeNode.tags = []; + } + treeNode.type = itemType; + treeNode.pos = treeNode.index; + treeNode.bmkUri = treeNode.uri; + records.push(treeNode); + if (treeNode.guid in lazy.ROOT_GUID_TO_QUERY_FOLDER_NAME) { + let queryId = lazy.ROOT_GUID_TO_QUERY_FOLDER_NAME[treeNode.guid]; + recordsByQueryId.set(queryId, treeNode); + } + if (localId) { + // Always add the ID, since it's still possible for a query to + // reference a root without using the well-known name. For example, + // `place:folder=${PlacesUtils.mobileFolderId}` and + // `place:folder=MOBILE_BOOKMARKS` are equivalent. + recordsByQueryId.set(localId.toString(10), treeNode); + } + if (treeNode.type === "folder") { + treeNode.childGUIDs = []; + if (!treeNode.children) { + treeNode.children = []; + } + + await lazy.Async.yieldingForEach( + treeNode.children, + async child => { + await traverse(child, synced); + child.parent = treeNode; + child.parentid = guid; + treeNode.childGUIDs.push(child.guid); + }, + this.yieldState + ); + } + }; + + await traverse(clientTree, false); + + clientTree.id = "places"; + await this._followQueries(recordsByQueryId); + return records; + } + + /** + * Process the server-side list. Mainly this builds the records into a tree, + * but it also records information about problems, and produces arrays of the + * deleted and non-deleted nodes. + * + * Returns an object containing: + * - records:Array of non-deleted records. Each record contains the following + * properties + * - childGUIDs (array of strings, only present if type is 'folder'): the + * list of child GUIDs stored on the server. + * - children (array of records, only present if type is 'folder'): + * each record has these same properties. This may differ in content + * from what you may expect from the childGUIDs list, as it won't + * contain any records that could not be found. + * - parent (record): The parent to this record. + * - Unchanged properties send down from the server: id, title, type, + * parentName, parentid, bmkURI, keyword, tags, pos, queryId + * - root: Root of the server-side bookmark tree. Has the same properties as + * above. + * - deletedRecords: As above, but only contains items that the server sent + * where it also sent indication that the item should be deleted. + * - problemData: a BookmarkProblemData object, with the caveat that + * the fields describing client/server relationship will not have been filled + * out yet. + */ + async inspectServerRecords(serverRecords) { + const data = await ServerRecordInspection.create(serverRecords); + return { + deletedRecords: data.deletedRecords, + records: data.liveRecords, + problemData: data.problemData, + root: data.root, + }; + } + + // Perform client-side sanity checking that doesn't involve server data + async _validateClient(problemData, clientRecords) { + problemData.clientCycles = await detectCycles(clientRecords); + for (let rootGUID of lazy.SYNCED_ROOTS) { + let record = clientRecords.find(record => record.guid === rootGUID); + if (!record || record.parentid !== "places") { + problemData.badClientRoots.push(rootGUID); + } + } + } + + async _computeUnifiedRecordMap(serverRecords, clientRecords) { + let allRecords = new Map(); + await lazy.Async.yieldingForEach( + serverRecords, + sr => { + if (sr.fake) { + return; + } + allRecords.set(sr.id, { client: null, server: sr }); + }, + this.yieldState + ); + + await lazy.Async.yieldingForEach( + clientRecords, + cr => { + let unified = allRecords.get(cr.id); + if (!unified) { + allRecords.set(cr.id, { client: cr, server: null }); + } else { + unified.client = cr; + } + }, + this.yieldState + ); + + return allRecords; + } + + _recordMissing(problems, id, clientRecord, serverRecord, serverTombstones) { + if (!clientRecord && serverRecord) { + problems.clientMissing.push(id); + } + if (!serverRecord && clientRecord) { + if (serverTombstones.has(id)) { + problems.serverDeleted.push(id); + } else if (!clientRecord.ignored && clientRecord.id != "places") { + problems.serverMissing.push(id); + } + } + } + + _compareRecords(client, server) { + let structuralDifferences = []; + let differences = []; + + // Don't bother comparing titles of roots. It's okay if locally it's + // "Mobile Bookmarks", but the server thinks it's "mobile". + // TODO: We probably should be handing other localized bookmarks (e.g. + // default bookmarks) here as well, see bug 1316041. + if (!lazy.SYNCED_ROOTS.includes(client.guid)) { + // We want to treat undefined, null and an empty string as identical + if ((client.title || "") !== (server.title || "")) { + differences.push("title"); + } + } + + if (client.parentid || server.parentid) { + if (client.parentid !== server.parentid) { + structuralDifferences.push("parentid"); + } + } + + if (client.tags || server.tags) { + let cl = client.tags ? [...client.tags].sort() : []; + let sl = server.tags ? [...server.tags].sort() : []; + if (!CommonUtils.arrayEqual(cl, sl)) { + differences.push("tags"); + } + } + + let sameType = client.type === server.type; + if (!sameType) { + if ( + server.type === "query" && + client.type === "bookmark" && + client.bmkUri.startsWith(QUERY_PROTOCOL) + ) { + sameType = true; + } + } + + if (!sameType) { + differences.push("type"); + } else { + switch (server.type) { + case "bookmark": + case "query": + if (!areURLsEqual(server.bmkUri, client.bmkUri)) { + differences.push("bmkUri"); + } + break; + case "separator": + if (server.pos != client.pos) { + differences.push("pos"); + } + break; + case "livemark": + if (server.feedUri != client.feedUri) { + differences.push("feedUri"); + } + if (server.siteUri != client.siteUri) { + differences.push("siteUri"); + } + break; + case "folder": + if (server.id === "places" && server.fake) { + // It's the fabricated places root. It won't have the GUIDs, but + // it doesn't matter. + break; + } + if (client.childGUIDs || server.childGUIDs) { + let cl = client.childGUIDs || []; + let sl = server.childGUIDs || []; + if (!CommonUtils.arrayEqual(cl, sl)) { + structuralDifferences.push("childGUIDs"); + } + } + break; + } + } + return { differences, structuralDifferences }; + } + + /** + * Compare the list of server records with the client tree. + * + * Returns the same data as described in the inspectServerRecords comment, + * with the following additional fields. + * - clientRecords: an array of client records in a similar format to + * the .records (ie, server records) entry. + * - problemData is the same as for inspectServerRecords, except all properties + * will be filled out. + */ + async compareServerWithClient(serverRecords, clientTree) { + let clientRecords = await this.createClientRecordsFromTree(clientTree); + let inspectionInfo = await this.inspectServerRecords(serverRecords); + inspectionInfo.clientRecords = clientRecords; + + // Mainly do this to remove deleted items and normalize child guids. + serverRecords = inspectionInfo.records; + let problemData = inspectionInfo.problemData; + + await this._validateClient(problemData, clientRecords); + + let allRecords = await this._computeUnifiedRecordMap( + serverRecords, + clientRecords + ); + + let serverDeleted = new Set(inspectionInfo.deletedRecords.map(r => r.id)); + + await lazy.Async.yieldingForEach( + allRecords, + ([id, { client, server }]) => { + if (!client || !server) { + this._recordMissing(problemData, id, client, server, serverDeleted); + return; + } + if (server && client && client.ignored) { + problemData.serverUnexpected.push(id); + } + let { differences, structuralDifferences } = this._compareRecords( + client, + server + ); + + if (differences.length) { + problemData.differences.push({ id, differences }); + } + if (structuralDifferences.length) { + problemData.structuralDifferences.push({ + id, + differences: structuralDifferences, + }); + } + }, + this.yieldState + ); + + return inspectionInfo; + } + + async _getServerState(engine) { + let collection = engine.itemSource(); + let collectionKey = engine.service.collectionKeys.keyForCollection( + engine.name + ); + collection.full = true; + let result = await collection.getBatched(); + if (!result.response.success) { + throw result.response; + } + let cleartexts = []; + await lazy.Async.yieldingForEach( + result.records, + async record => { + await record.decrypt(collectionKey); + cleartexts.push(record.cleartext); + }, + this.yieldState + ); + return cleartexts; + } + + async validate(engine) { + let start = Date.now(); + let clientTree = await lazy.PlacesUtils.promiseBookmarksTree("", { + includeItemIds: true, + }); + let serverState = await this._getServerState(engine); + let serverRecordCount = serverState.length; + let result = await this.compareServerWithClient(serverState, clientTree); + let end = Date.now(); + let duration = end - start; + + engine._log.debug(`Validated bookmarks in ${duration}ms`); + engine._log.debug(`Problem summary`); + for (let { name, count } of result.problemData.getSummary()) { + engine._log.debug(` ${name}: ${count}`); + } + + return { + duration, + version: this.version, + problems: result.problemData, + recordCount: serverRecordCount, + }; + } +} + +BookmarkValidator.prototype.version = BOOKMARK_VALIDATOR_VERSION; diff --git a/services/sync/tps/extensions/tps/resource/modules/bookmarks.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/bookmarks.sys.mjs new file mode 100644 index 0000000000..e4aac948b5 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/bookmarks.sys.mjs @@ -0,0 +1,833 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + * ChromeUtils.import() and acts as a singleton. Only the following + * listed symbols will exposed on import, and only when and where imported. + */ + +import { PlacesBackups } from "resource://gre/modules/PlacesBackups.sys.mjs"; + +import { PlacesSyncUtils } from "resource://gre/modules/PlacesSyncUtils.sys.mjs"; +import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs"; + +import { Logger } from "resource://tps/logger.sys.mjs"; + +export async function DumpBookmarks() { + let [bookmarks] = await PlacesBackups.getBookmarksTree(); + Logger.logInfo( + "Dumping Bookmarks...\n" + JSON.stringify(bookmarks, undefined, 2) + "\n\n" + ); +} + +/** + * extend, causes a child object to inherit from a parent + */ +function extend(child, supertype) { + Object.setPrototypeOf(child.prototype, supertype.prototype); +} +/** + * PlacesItemProps object, holds properties for places items + */ +function PlacesItemProps(props) { + this.location = null; + this.uri = null; + this.keyword = null; + this.title = null; + this.after = null; + this.before = null; + this.folder = null; + this.position = null; + this.delete = false; + this.tags = null; + this.last_item_pos = null; + this.type = null; + + for (var prop in props) { + if (prop in this) { + this[prop] = props[prop]; + } + } +} + +/** + * PlacesItem object. Base class for places items. + */ +export function PlacesItem(props) { + this.props = new PlacesItemProps(props); + if (this.props.location == null) { + this.props.location = "menu"; + } + if ("changes" in props) { + this.updateProps = new PlacesItemProps(props.changes); + } else { + this.updateProps = null; + } +} + +/** + * Instance methods for generic places items. + */ +PlacesItem.prototype = { + // an array of possible root folders for places items + _bookmarkFolders: { + places: PlacesUtils.bookmarks.rootGuid, + menu: PlacesUtils.bookmarks.menuGuid, + tags: PlacesUtils.bookmarks.tagsGuid, + unfiled: PlacesUtils.bookmarks.unfiledGuid, + toolbar: PlacesUtils.bookmarks.toolbarGuid, + mobile: PlacesUtils.bookmarks.mobileGuid, + }, + + _typeMap: new Map([ + [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, PlacesUtils.bookmarks.TYPE_FOLDER], + [ + PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + ], + [PlacesUtils.TYPE_X_MOZ_PLACE, PlacesUtils.bookmarks.TYPE_BOOKMARK], + ]), + + toString() { + var that = this; + var props = ["uri", "title", "location", "folder"]; + var string = + (this.props.type ? this.props.type + " " : "") + + "(" + + (function () { + var ret = []; + for (var i in props) { + if (that.props[props[i]]) { + ret.push(props[i] + ": " + that.props[props[i]]); + } + } + return ret; + })().join(", ") + + ")"; + return string; + }, + + /** + * GetPlacesChildGuid + * + * Finds the guid of the an item with the specified properties in the places + * database under the specified parent. + * + * @param folder The guid of the folder to search + * @param type The type of the item to find, or null to match any item; + * this is one of the PlacesUtils.bookmarks.TYPE_* values + * @param title The title of the item to find, or null to match any title + * @param uri The uri of the item to find, or null to match any uri + * + * @return the node id if the item was found, otherwise null + */ + async GetPlacesChildGuid(folder, type, title, uri) { + let children = (await PlacesUtils.promiseBookmarksTree(folder)).children; + if (!children) { + return null; + } + let guid = null; + for (let node of children) { + if (node.title == title) { + let nodeType = this._typeMap.get(node.type); + if (type == null || type == undefined || nodeType == type) { + if (uri == undefined || uri == null || node.uri.spec == uri.spec) { + // Note that this is suspect as we return the *last* matching + // child, which some tests rely on (ie, an early-return here causes + // at least 1 test to fail). But that's a yak for another day. + guid = node.guid; + } + } + } + } + return guid; + }, + + /** + * IsAdjacentTo + * + * Determines if this object is immediately adjacent to another. + * + * @param itemName The name of the other object; this may be any kind of + * places item + * @param relativePos The relative position of the other object. If -1, + * it means the other object should precede this one, if +1, + * the other object should come after this one + * @return true if this object is immediately adjacent to the other object, + * otherwise false + */ + async IsAdjacentTo(itemName, relativePos) { + Logger.AssertTrue( + this.props.folder_id != -1 && this.props.guid != null, + "Either folder_id or guid was invalid" + ); + let otherGuid = await this.GetPlacesChildGuid( + this.props.parentGuid, + null, + itemName + ); + Logger.AssertTrue(otherGuid, "item " + itemName + " not found"); + let other_pos = (await PlacesUtils.bookmarks.fetch(otherGuid)).index; + let this_pos = (await PlacesUtils.bookmarks.fetch(this.props.guid)).index; + if (other_pos + relativePos != this_pos) { + Logger.logPotentialError( + "Invalid position - " + + (this.props.title ? this.props.title : this.props.folder) + + " not " + + (relativePos == 1 ? "after " : "before ") + + itemName + + " for " + + this.toString() + ); + return false; + } + return true; + }, + + /** + * GetItemIndex + * + * Gets the item index for this places item. + * + * @return the item index, or -1 if there's an error + */ + async GetItemIndex() { + if (this.props.guid == null) { + return -1; + } + return (await PlacesUtils.bookmarks.fetch(this.props.guid)).index; + }, + + /** + * GetFolder + * + * Gets the folder guid for the specified bookmark folder + * + * @param location The full path of the folder, which must begin + * with one of the bookmark root folders + * @return the folder guid if the folder is found, otherwise null + */ + async GetFolder(location) { + let folder_parts = location.split("/"); + if (!(folder_parts[0] in this._bookmarkFolders)) { + return null; + } + let folderGuid = this._bookmarkFolders[folder_parts[0]]; + for (let i = 1; i < folder_parts.length; i++) { + let guid = await this.GetPlacesChildGuid( + folderGuid, + PlacesUtils.bookmarks.TYPE_FOLDER, + folder_parts[i] + ); + if (guid == null) { + return null; + } + folderGuid = guid; + } + return folderGuid; + }, + + /** + * CreateFolder + * + * Creates a bookmark folder. + * + * @param location The full path of the folder, which must begin + * with one of the bookmark root folders + * @return the folder id if the folder was created, otherwise -1 + */ + async CreateFolder(location) { + let folder_parts = location.split("/"); + if (!(folder_parts[0] in this._bookmarkFolders)) { + return -1; + } + let folderGuid = this._bookmarkFolders[folder_parts[0]]; + for (let i = 1; i < folder_parts.length; i++) { + let subfolderGuid = await this.GetPlacesChildGuid( + folderGuid, + PlacesUtils.bookmarks.TYPE_FOLDER, + folder_parts[i] + ); + if (subfolderGuid == null) { + let { guid } = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + name: folder_parts[i], + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + folderGuid = guid; + } else { + folderGuid = subfolderGuid; + } + } + return folderGuid; + }, + + /** + * GetOrCreateFolder + * + * Locates the specified folder; if not found it is created. + * + * @param location The full path of the folder, which must begin + * with one of the bookmark root folders + * @return the folder id if the folder was found or created, otherwise -1 + */ + async GetOrCreateFolder(location) { + let parentGuid = await this.GetFolder(location); + if (parentGuid == null) { + parentGuid = await this.CreateFolder(location); + } + return parentGuid; + }, + + /** + * CheckPosition + * + * Verifies the position of this places item. + * + * @param before The name of the places item that this item should be + before, or null if this check should be skipped + * @param after The name of the places item that this item should be + after, or null if this check should be skipped + * @param last_item_pos The index of the places item above this one, + * or null if this check should be skipped + * @return true if this item is in the correct position, otherwise false + */ + async CheckPosition(before, after, last_item_pos) { + if (after) { + if (!(await this.IsAdjacentTo(after, 1))) { + return false; + } + } + if (before) { + if (!(await this.IsAdjacentTo(before, -1))) { + return false; + } + } + if (last_item_pos != null && last_item_pos > -1) { + let index = await this.GetItemIndex(); + if (index != last_item_pos + 1) { + Logger.logPotentialError( + "Item not found at the expected index, got " + + index + + ", expected " + + (last_item_pos + 1) + + " for " + + this.toString() + ); + return false; + } + } + return true; + }, + + /** + * SetLocation + * + * Moves this places item to a different folder. + * + * @param location The full path of the folder to which to move this + * places item, which must begin with one of the bookmark root + * folders; if null, no changes are made + * @return nothing if successful, otherwise an exception is thrown + */ + async SetLocation(location) { + if (location != null) { + let newfolderGuid = await this.GetOrCreateFolder(location); + Logger.AssertTrue( + newfolderGuid, + "Location " + location + " doesn't exist; can't change item's location" + ); + await PlacesUtils.bookmarks.update({ + guid: this.props.guid, + parentGuid: newfolderGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + this.props.parentGuid = newfolderGuid; + } + }, + + /** + * SetPosition + * + * Updates the position of this places item within this item's current + * folder. Use SetLocation to change folders. + * + * @param position The new index this item should be moved to; if null, + * no changes are made; if -1, this item is moved to the bottom of + * the current folder. Otherwise, must be a string which is the + * title of an existing item in the folder, who's current position + * is used as the index. + * @return nothing if successful, otherwise an exception is thrown + */ + async SetPosition(position) { + if (position == null) { + return; + } + let index = -1; + if (position != -1) { + let existingGuid = await this.GetPlacesChildGuid( + this.props.parentGuid, + null, + position + ); + if (existingGuid) { + index = (await PlacesUtils.bookmarks.fetch(existingGuid)).index; + } + Logger.AssertTrue( + index != -1, + "position " + position + " is invalid; unable to change position" + ); + } + await PlacesUtils.bookmarks.update({ guid: this.props.guid, index }); + }, + + /** + * Update the title of this places item + * + * @param title The new title to set for this item; if null, no changes + * are made + * @return nothing + */ + async SetTitle(title) { + if (title != null) { + await PlacesUtils.bookmarks.update({ guid: this.props.guid, title }); + } + }, +}; + +/** + * Bookmark class constructor. Initializes instance properties. + */ +export function Bookmark(props) { + PlacesItem.call(this, props); + if (this.props.title == null) { + this.props.title = this.props.uri; + } + this.props.type = "bookmark"; +} + +/** + * Bookmark instance methods. + */ +Bookmark.prototype = { + /** + * SetKeyword + * + * Update this bookmark's keyword. + * + * @param keyword The keyword to set for this bookmark; if null, no + * changes are made + * @return nothing + */ + async SetKeyword(keyword) { + if (keyword != null) { + // Mirror logic from PlacesSyncUtils's updateBookmarkMetadata + let entry = await PlacesUtils.keywords.fetch({ url: this.props.uri }); + if (entry) { + await PlacesUtils.keywords.remove(entry); + } + await PlacesUtils.keywords.insert({ keyword, url: this.props.uri }); + } + }, + + /** + * SetUri + * + * Updates this bookmark's URI. + * + * @param uri The new URI to set for this boomark; if null, no changes + * are made + * @return nothing + */ + async SetUri(uri) { + if (uri) { + let url = Services.io.newURI(uri); + await PlacesUtils.bookmarks.update({ guid: this.props.guid, url }); + } + }, + + /** + * SetTags + * + * Updates this bookmark's tags. + * + * @param tags An array of tags which should be associated with this + * bookmark; any previous tags are removed; if this param is null, + * no changes are made. If this param is an empty array, all + * tags are removed from this bookmark. + * @return nothing + */ + SetTags(tags) { + if (tags != null) { + let URI = Services.io.newURI(this.props.uri); + PlacesUtils.tagging.untagURI(URI, null); + if (tags.length) { + PlacesUtils.tagging.tagURI(URI, tags); + } + } + }, + + /** + * Create + * + * Creates the bookmark described by this object's properties. + * + * @return the id of the created bookmark + */ + async Create() { + this.props.parentGuid = await this.GetOrCreateFolder(this.props.location); + Logger.AssertTrue( + this.props.parentGuid, + "Unable to create " + + "bookmark, error creating folder " + + this.props.location + ); + let bookmarkURI = Services.io.newURI(this.props.uri); + let { guid } = await PlacesUtils.bookmarks.insert({ + parentGuid: this.props.parentGuid, + url: bookmarkURI, + title: this.props.title, + }); + this.props.guid = guid; + await this.SetKeyword(this.props.keyword); + await this.SetTags(this.props.tags); + return this.props.guid; + }, + + /** + * Update + * + * Updates this bookmark's properties according the properties on this + * object's 'updateProps' property. + * + * @return nothing + */ + async Update() { + Logger.AssertTrue(this.props.guid, "Invalid guid during Update"); + await this.SetTitle(this.updateProps.title); + await this.SetUri(this.updateProps.uri); + await this.SetKeyword(this.updateProps.keyword); + await this.SetTags(this.updateProps.tags); + await this.SetLocation(this.updateProps.location); + await this.SetPosition(this.updateProps.position); + }, + + /** + * Find + * + * Locates the bookmark which corresponds to this object's properties. + * + * @return the bookmark guid if the bookmark was found, otherwise null + */ + async Find() { + this.props.parentGuid = await this.GetFolder(this.props.location); + + if (this.props.parentGuid == null) { + Logger.logError("Unable to find folder " + this.props.location); + return null; + } + let bookmarkTitle = this.props.title; + this.props.guid = await this.GetPlacesChildGuid( + this.props.parentGuid, + null, + bookmarkTitle, + this.props.uri + ); + + if (!this.props.guid) { + Logger.logPotentialError(this.toString() + " not found"); + return null; + } + if (this.props.keyword != null) { + let { keyword } = await PlacesSyncUtils.bookmarks.fetch(this.props.guid); + if (keyword != this.props.keyword) { + Logger.logPotentialError( + "Incorrect keyword - expected: " + + this.props.keyword + + ", actual: " + + keyword + + " for " + + this.toString() + ); + return null; + } + } + if (this.props.tags != null) { + try { + let URI = Services.io.newURI(this.props.uri); + let tags = PlacesUtils.tagging.getTagsForURI(URI); + tags.sort(); + this.props.tags.sort(); + if (JSON.stringify(tags) != JSON.stringify(this.props.tags)) { + Logger.logPotentialError( + "Wrong tags - expected: " + + JSON.stringify(this.props.tags) + + ", actual: " + + JSON.stringify(tags) + + " for " + + this.toString() + ); + return null; + } + } catch (e) { + Logger.logPotentialError("error processing tags " + e); + return null; + } + } + if ( + !(await this.CheckPosition( + this.props.before, + this.props.after, + this.props.last_item_pos + )) + ) { + return null; + } + return this.props.guid; + }, + + /** + * Remove + * + * Removes this bookmark. The bookmark should have been located previously + * by a call to Find. + * + * @return nothing + */ + async Remove() { + Logger.AssertTrue(this.props.guid, "Invalid guid during Remove"); + await PlacesUtils.bookmarks.remove(this.props.guid); + }, +}; + +extend(Bookmark, PlacesItem); + +/** + * BookmarkFolder class constructor. Initializes instance properties. + */ +export function BookmarkFolder(props) { + PlacesItem.call(this, props); + this.props.type = "folder"; +} + +/** + * BookmarkFolder instance methods + */ +BookmarkFolder.prototype = { + /** + * Create + * + * Creates the bookmark folder described by this object's properties. + * + * @return the id of the created bookmark folder + */ + async Create() { + this.props.parentGuid = await this.GetOrCreateFolder(this.props.location); + Logger.AssertTrue( + this.props.parentGuid, + "Unable to create " + + "folder, error creating parent folder " + + this.props.location + ); + let { guid } = await PlacesUtils.bookmarks.insert({ + parentGuid: this.props.parentGuid, + title: this.props.folder, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + this.props.guid = guid; + return this.props.parentGuid; + }, + + /** + * Find + * + * Locates the bookmark folder which corresponds to this object's + * properties. + * + * @return the folder guid if the folder was found, otherwise null + */ + async Find() { + this.props.parentGuid = await this.GetFolder(this.props.location); + if (this.props.parentGuid == null) { + Logger.logError("Unable to find folder " + this.props.location); + return null; + } + this.props.guid = await this.GetPlacesChildGuid( + this.props.parentGuid, + PlacesUtils.bookmarks.TYPE_FOLDER, + this.props.folder + ); + if (this.props.guid == null) { + return null; + } + if ( + !(await this.CheckPosition( + this.props.before, + this.props.after, + this.props.last_item_pos + )) + ) { + return null; + } + return this.props.guid; + }, + + /** + * Remove + * + * Removes this folder. The folder should have been located previously + * by a call to Find. + * + * @return nothing + */ + async Remove() { + Logger.AssertTrue(this.props.guid, "Invalid guid during Remove"); + await PlacesUtils.bookmarks.remove(this.props.guid); + }, + + /** + * Update + * + * Updates this bookmark's properties according the properties on this + * object's 'updateProps' property. + * + * @return nothing + */ + async Update() { + Logger.AssertTrue(this.props.guid, "Invalid guid during Update"); + await this.SetLocation(this.updateProps.location); + await this.SetPosition(this.updateProps.position); + await this.SetTitle(this.updateProps.folder); + }, +}; + +extend(BookmarkFolder, PlacesItem); + +/** + * Separator class constructor. Initializes instance properties. + */ +export function Separator(props) { + PlacesItem.call(this, props); + this.props.type = "separator"; +} + +/** + * Separator instance methods. + */ +Separator.prototype = { + /** + * Create + * + * Creates the bookmark separator described by this object's properties. + * + * @return the id of the created separator + */ + async Create() { + this.props.parentGuid = await this.GetOrCreateFolder(this.props.location); + Logger.AssertTrue( + this.props.parentGuid, + "Unable to create " + + "folder, error creating parent folder " + + this.props.location + ); + let { guid } = await PlacesUtils.bookmarks.insert({ + parentGuid: this.props.parentGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + this.props.guid = guid; + return guid; + }, + + /** + * Find + * + * Locates the bookmark separator which corresponds to this object's + * properties. + * + * @return the item guid if the separator was found, otherwise null + */ + async Find() { + this.props.parentGuid = await this.GetFolder(this.props.location); + if (this.props.parentGuid == null) { + Logger.logError("Unable to find folder " + this.props.location); + return null; + } + if (this.props.before == null && this.props.last_item_pos == null) { + Logger.logPotentialError( + "Separator requires 'before' attribute if it's the" + + "first item in the list" + ); + return null; + } + let expected_pos = -1; + if (this.props.before) { + let otherGuid = this.GetPlacesChildGuid( + this.props.parentGuid, + null, + this.props.before + ); + if (otherGuid == null) { + Logger.logPotentialError( + "Can't find places item " + + this.props.before + + " for locating separator" + ); + return null; + } + expected_pos = (await PlacesUtils.bookmarks.fetch(otherGuid)).index - 1; + } else { + expected_pos = this.props.last_item_pos + 1; + } + // Note these are IDs instead of GUIDs. + let children = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + this.props.parentGuid + ); + this.props.guid = children[expected_pos]; + if (this.props.guid == null) { + Logger.logPotentialError( + "No separator found at position " + expected_pos + ); + return null; + } + let info = await PlacesUtils.bookmarks.fetch(this.props.guid); + if (info.type != PlacesUtils.bookmarks.TYPE_SEPARATOR) { + Logger.logPotentialError( + "Places item at position " + expected_pos + " is not a separator" + ); + return null; + } + return this.props.guid; + }, + + /** + * Update + * + * Updates this separator's properties according the properties on this + * object's 'updateProps' property. + * + * @return nothing + */ + async Update() { + Logger.AssertTrue(this.props.guid, "Invalid guid during Update"); + await this.SetLocation(this.updateProps.location); + await this.SetPosition(this.updateProps.position); + return true; + }, + + /** + * Remove + * + * Removes this separator. The separator should have been located + * previously by a call to Find. + * + * @return nothing + */ + async Remove() { + Logger.AssertTrue(this.props.guid, "Invalid guid during Update"); + await PlacesUtils.bookmarks.remove(this.props.guid); + }, +}; + +extend(Separator, PlacesItem); diff --git a/services/sync/tps/extensions/tps/resource/modules/formautofill.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/formautofill.sys.mjs new file mode 100644 index 0000000000..587d7668f4 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/formautofill.sys.mjs @@ -0,0 +1,128 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + * ChromeUtils.import() and acts as a singleton. Only the following + * listed symbols will exposed on import, and only when and where imported. + */ + +import { Logger } from "resource://tps/logger.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", + formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs", +}); + +class FormAutofillBase { + constructor(props, subStorageName, fields) { + this._subStorageName = subStorageName; + this._fields = fields; + + this.props = {}; + this.updateProps = null; + if ("changes" in props) { + this.updateProps = props.changes; + } + for (const field of this._fields) { + this.props[field] = field in props ? props[field] : null; + } + } + + async getStorage() { + await lazy.formAutofillStorage.initialize(); + return lazy.formAutofillStorage[this._subStorageName]; + } + + async Create() { + const storage = await this.getStorage(); + await storage.add(this.props); + } + + async Find() { + const storage = await this.getStorage(); + return storage._data.find(entry => + this._fields.every(field => entry[field] === this.props[field]) + ); + } + + async Update() { + const storage = await this.getStorage(); + const { guid } = await this.Find(); + await storage.update(guid, this.updateProps, true); + } + + async Remove() { + const storage = await this.getStorage(); + const { guid } = await this.Find(); + storage.remove(guid); + } +} + +async function DumpStorage(subStorageName) { + await lazy.formAutofillStorage.initialize(); + Logger.logInfo(`\ndumping ${subStorageName} list\n`, true); + const entries = lazy.formAutofillStorage[subStorageName]._data; + for (const entry of entries) { + Logger.logInfo(JSON.stringify(entry), true); + } + Logger.logInfo(`\n\nend ${subStorageName} list\n`, true); +} + +const ADDRESS_FIELDS = [ + "given-name", + "additional-name", + "family-name", + "organization", + "street-address", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", +]; + +export class Address extends FormAutofillBase { + constructor(props) { + super(props, "addresses", ADDRESS_FIELDS); + } +} + +export async function DumpAddresses() { + await DumpStorage("addresses"); +} + +const CREDIT_CARD_FIELDS = [ + "cc-name", + "cc-number", + "cc-exp-month", + "cc-exp-year", +]; + +export class CreditCard extends FormAutofillBase { + constructor(props) { + super(props, "creditCards", CREDIT_CARD_FIELDS); + } + + async Find() { + const storage = await this.getStorage(); + await Promise.all( + storage._data.map( + async entry => + (entry["cc-number"] = await lazy.OSKeyStore.decrypt( + entry["cc-number-encrypted"] + )) + ) + ); + return storage._data.find(entry => { + return this._fields.every(field => entry[field] === this.props[field]); + }); + } +} + +export async function DumpCreditCards() { + await DumpStorage("creditCards"); +} diff --git a/services/sync/tps/extensions/tps/resource/modules/forms.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/forms.sys.mjs new file mode 100644 index 0000000000..35b5f5c03b --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/forms.sys.mjs @@ -0,0 +1,205 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + ChromeUtils.import() and acts as a singleton. Only the following + listed symbols will exposed on import, and only when and where imported. + */ + +import { Logger } from "resource://tps/logger.sys.mjs"; + +import { FormHistory } from "resource://gre/modules/FormHistory.sys.mjs"; + +/** + * FormDB + * + * Helper object containing methods to interact with the FormHistory module. + */ +var FormDB = { + async _update(data) { + await FormHistory.update(data); + }, + + /** + * insertValue + * + * Adds the specified value for the specified fieldname into form history. + * + * @param fieldname The form fieldname to insert + * @param value The form value to insert + * @param us The time, in microseconds, to use for the lastUsed + * and firstUsed columns + * @return Promise<undefined> + */ + insertValue(fieldname, value, us) { + let data = { + op: "add", + fieldname, + value, + timesUsed: 1, + firstUsed: us, + lastUsed: us, + }; + return this._update(data); + }, + + /** + * updateValue + * + * Updates a row in the moz_formhistory table with a new value. + * + * @param id The id of the row to update + * @param newvalue The new value to set + * @return Promise<undefined> + */ + updateValue(id, newvalue) { + return this._update({ op: "update", guid: id, value: newvalue }); + }, + + /** + * getDataForValue + * + * Retrieves a set of values for a row in the database that + * corresponds to the given fieldname and value. + * + * @param fieldname The fieldname of the row to query + * @param value The value of the row to query + * @return Promise<null if no row is found with the specified fieldname and value, + * or an object containing the row's guid, lastUsed, and firstUsed + * values> + */ + async getDataForValue(fieldname, value) { + let results = await FormHistory.search(["guid", "lastUsed", "firstUsed"], { + fieldname, + value, + }); + if (results.length > 1) { + throw new Error("more than 1 result for this query"); + } + return results; + }, + + /** + * remove + * + * Removes the specified GUID from the database. + * + * @param guid The guid of the item to delete + * @return Promise<> + */ + remove(guid) { + return this._update({ op: "remove", guid }); + }, +}; + +/** + * FormData class constructor + * + * Initializes instance properties. + */ +export function FormData(props, msSinceEpoch) { + this.fieldname = null; + this.value = null; + this.date = 0; + this.newvalue = null; + this.usSinceEpoch = msSinceEpoch * 1000; + + for (var prop in props) { + if (prop in this) { + this[prop] = props[prop]; + } + } +} + +/** + * FormData instance methods + */ +FormData.prototype = { + /** + * hours_to_us + * + * Converts hours since present to microseconds since epoch. + * + * @param hours The number of hours since the present time (e.g., 0 is + * 'now', and -1 is 1 hour ago) + * @return the corresponding number of microseconds since the epoch + */ + hours_to_us(hours) { + return this.usSinceEpoch + hours * 60 * 60 * 1000 * 1000; + }, + + /** + * Create + * + * If this FormData object doesn't exist in the moz_formhistory database, + * add it. Throws on error. + * + * @return nothing + */ + Create() { + Logger.AssertTrue( + this.fieldname != null && this.value != null, + "Must specify both fieldname and value" + ); + + return FormDB.getDataForValue(this.fieldname, this.value).then(formdata => { + if (!formdata) { + // this item doesn't exist yet in the db, so we need to insert it + return FormDB.insertValue( + this.fieldname, + this.value, + this.hours_to_us(this.date) + ); + } + /* Right now, we ignore this case. If bug 552531 is ever fixed, + we might need to add code here to update the firstUsed or + lastUsed fields, as appropriate. + */ + return null; + }); + }, + + /** + * Find + * + * Attempts to locate an entry in the moz_formhistory database that + * matches the fieldname and value for this FormData object. + * + * @return true if this entry exists in the database, otherwise false + */ + Find() { + return FormDB.getDataForValue(this.fieldname, this.value).then(formdata => { + let status = formdata != null; + if (status) { + /* + //form history dates currently not synced! bug 552531 + let us = this.hours_to_us(this.date); + status = Logger.AssertTrue( + us >= formdata.firstUsed && us <= formdata.lastUsed, + "No match for with that date value"); + + if (status) + */ + this.id = formdata.guid; + } + return status; + }); + }, + + /** + * Remove + * + * Removes the row represented by this FormData instance from the + * moz_formhistory database. + * + * @return nothing + */ + async Remove() { + const formdata = await FormDB.getDataForValue(this.fieldname, this.value); + if (!formdata) { + return; + } + await FormDB.remove(formdata.guid); + }, +}; diff --git a/services/sync/tps/extensions/tps/resource/modules/history.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/history.sys.mjs new file mode 100644 index 0000000000..845bab3aa9 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/history.sys.mjs @@ -0,0 +1,158 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + * ChromeUtils.import() and acts as a singleton. Only the following + * listed symbols will exposed on import, and only when and where imported. + */ + +import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs"; + +import { PlacesSyncUtils } from "resource://gre/modules/PlacesSyncUtils.sys.mjs"; + +import { Logger } from "resource://tps/logger.sys.mjs"; + +export var DumpHistory = async function TPS_History__DumpHistory() { + let query = PlacesUtils.history.getNewQuery(); + let options = PlacesUtils.history.getNewQueryOptions(); + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + Logger.logInfo("\n\ndumping history\n", true); + for (var i = 0; i < root.childCount; i++) { + let node = root.getChild(i); + let uri = node.uri; + let guid = await PlacesSyncUtils.history + .fetchGuidForURL(uri) + .catch(() => "?".repeat(12)); + let curvisits = await PlacesSyncUtils.history.fetchVisitsForURL(uri); + for (var visit of curvisits) { + Logger.logInfo( + `GUID: ${guid}, URI: ${uri}, type=${visit.type}, date=${visit.date}`, + true + ); + } + } + root.containerOpen = false; + Logger.logInfo("\nend history dump\n", true); +}; + +/** + * HistoryEntry object + * + * Contains methods for manipulating browser history entries. + */ +export var HistoryEntry = { + /** + * Add + * + * Adds visits for a uri to the history database. Throws on error. + * + * @param item An object representing one or more visits to a specific uri + * @param usSinceEpoch The number of microseconds from Epoch to + * the time the current Crossweave run was started + * @return nothing + */ + async Add(item, msSinceEpoch) { + Logger.AssertTrue( + "visits" in item && "uri" in item, + "History entry in test file must have both 'visits' " + + "and 'uri' properties" + ); + let place = { + url: item.uri, + visits: [], + }; + for (let visit of item.visits) { + let date = new Date( + Math.round(msSinceEpoch + visit.date * 60 * 60 * 1000) + ); + place.visits.push({ date, transition: visit.type }); + } + if ("title" in item) { + place.title = item.title; + } + return PlacesUtils.history.insert(place); + }, + + /** + * Find + * + * Finds visits for a uri to the history database. Throws on error. + * + * @param item An object representing one or more visits to a specific uri + * @param usSinceEpoch The number of microseconds from Epoch to + * the time the current Crossweave run was started + * @return true if all the visits for the uri are found, otherwise false + */ + async Find(item, msSinceEpoch) { + Logger.AssertTrue( + "visits" in item && "uri" in item, + "History entry in test file must have both 'visits' " + + "and 'uri' properties" + ); + let curvisits = await PlacesSyncUtils.history.fetchVisitsForURL(item.uri); + for (let visit of curvisits) { + for (let itemvisit of item.visits) { + // Note: in microseconds. + let expectedDate = + itemvisit.date * 60 * 60 * 1000 * 1000 + msSinceEpoch * 1000; + if (visit.type == itemvisit.type) { + if (itemvisit.date === undefined || visit.date == expectedDate) { + itemvisit.found = true; + } + } + } + } + + let all_items_found = true; + for (let itemvisit of item.visits) { + all_items_found = all_items_found && "found" in itemvisit; + Logger.logInfo( + `History entry for ${item.uri}, type: ${itemvisit.type}, date: ${itemvisit.date}` + + `(${ + itemvisit.date * 60 * 60 * 1000 * 1000 + }), found = ${!!itemvisit.found}` + ); + } + return all_items_found; + }, + + /** + * Delete + * + * Removes visits from the history database. Throws on error. + * + * @param item An object representing items to delete + * @param usSinceEpoch The number of microseconds from Epoch to + * the time the current Crossweave run was started + * @return nothing + */ + async Delete(item, msSinceEpoch) { + if ("uri" in item) { + let removedAny = await PlacesUtils.history.remove(item.uri); + if (!removedAny) { + Logger.log("Warning: Removed 0 history visits for uri " + item.uri); + } + } else if ("host" in item) { + await PlacesUtils.history.removeByFilter({ host: item.host }); + } else if ("begin" in item && "end" in item) { + let filter = { + beginDate: new Date(msSinceEpoch + item.begin * 60 * 60 * 1000), + endDate: new Date(msSinceEpoch + item.end * 60 * 60 * 1000), + }; + let removedAny = await PlacesUtils.history.removeVisitsByFilter(filter); + if (!removedAny) { + Logger.log( + "Warning: Removed 0 history visits with " + + JSON.stringify({ item, filter }) + ); + } + } else { + Logger.AssertTrue( + false, + "invalid entry in delete history " + JSON.stringify(item) + ); + } + }, +}; diff --git a/services/sync/tps/extensions/tps/resource/modules/passwords.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/passwords.sys.mjs new file mode 100644 index 0000000000..12c6d90e98 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/passwords.sys.mjs @@ -0,0 +1,187 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + * ChromeUtils.import() and acts as a singleton. Only the following + * listed symbols will exposed on import, and only when and where imported. + */ + +import { Logger } from "resource://tps/logger.sys.mjs"; + +var nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +export var DumpPasswords = function TPS__Passwords__DumpPasswords() { + let logins = Services.logins.getAllLogins(); + Logger.logInfo("\ndumping password list\n", true); + for (var i = 0; i < logins.length; i++) { + Logger.logInfo( + "* origin=" + + logins[i].origin + + ", formActionOrigin=" + + logins[i].formActionOrigin + + ", realm=" + + logins[i].httpRealm + + ", password=" + + logins[i].password + + ", passwordField=" + + logins[i].passwordField + + ", username=" + + logins[i].username + + ", usernameField=" + + logins[i].usernameField, + true + ); + } + Logger.logInfo("\n\nend password list\n", true); +}; + +/** + * PasswordProps object; holds password properties. + */ +function PasswordProps(props) { + this.hostname = null; + this.submitURL = null; + this.realm = null; + this.username = ""; + this.password = ""; + this.usernameField = ""; + this.passwordField = ""; + this.delete = false; + + for (var prop in props) { + if (prop in this) { + this[prop] = props[prop]; + } + } +} + +/** + * Password class constructor. Initializes instance properties. + */ +export function Password(props) { + this.props = new PasswordProps(props); + if ("changes" in props) { + this.updateProps = new PasswordProps(props); + for (var prop in props.changes) { + if (prop in this.updateProps) { + this.updateProps[prop] = props.changes[prop]; + } + } + } else { + this.updateProps = null; + } +} + +/** + * Password instance methods. + */ +Password.prototype = { + /** + * Create + * + * Adds a password entry to the login manager for the password + * represented by this object's properties. Throws on error. + * + * @return the new login guid + */ + async Create() { + let login = new nsLoginInfo( + this.props.hostname, + this.props.submitURL, + this.props.realm, + this.props.username, + this.props.password, + this.props.usernameField, + this.props.passwordField + ); + await Services.logins.addLoginAsync(login); + login.QueryInterface(Ci.nsILoginMetaInfo); + return login.guid; + }, + + /** + * Find + * + * Finds a password entry in the login manager, for the password + * represented by this object's properties. + * + * @return the guid of the password if found, otherwise -1 + */ + Find() { + let logins = Services.logins.findLogins( + this.props.hostname, + this.props.submitURL, + this.props.realm + ); + for (var i = 0; i < logins.length; i++) { + if ( + logins[i].username == this.props.username && + logins[i].password == this.props.password && + logins[i].usernameField == this.props.usernameField && + logins[i].passwordField == this.props.passwordField + ) { + logins[i].QueryInterface(Ci.nsILoginMetaInfo); + return logins[i].guid; + } + } + return -1; + }, + + /** + * Update + * + * Updates an existing password entry in the login manager with + * new properties. Throws on error. The 'old' properties are this + * object's properties, the 'new' properties are the properties in + * this object's 'updateProps' object. + * + * @return nothing + */ + Update() { + let oldlogin = new nsLoginInfo( + this.props.hostname, + this.props.submitURL, + this.props.realm, + this.props.username, + this.props.password, + this.props.usernameField, + this.props.passwordField + ); + let newlogin = new nsLoginInfo( + this.updateProps.hostname, + this.updateProps.submitURL, + this.updateProps.realm, + this.updateProps.username, + this.updateProps.password, + this.updateProps.usernameField, + this.updateProps.passwordField + ); + Services.logins.modifyLogin(oldlogin, newlogin); + }, + + /** + * Remove + * + * Removes an entry from the login manager for a password which + * matches this object's properties. Throws on error. + * + * @return nothing + */ + Remove() { + let login = new nsLoginInfo( + this.props.hostname, + this.props.submitURL, + this.props.realm, + this.props.username, + this.props.password, + this.props.usernameField, + this.props.passwordField + ); + Services.logins.removeLogin(login); + }, +}; diff --git a/services/sync/tps/extensions/tps/resource/modules/prefs.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/prefs.sys.mjs new file mode 100644 index 0000000000..9f6c423a40 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/prefs.sys.mjs @@ -0,0 +1,122 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + ChromeUtils.import() and acts as a singleton. + Only the following listed symbols will exposed on import, and only when + and where imported. */ + +const WEAVE_PREF_PREFIX = "services.sync.prefs.sync."; + +import { Logger } from "resource://tps/logger.sys.mjs"; + +/** + * Preference class constructor + * + * Initializes instance properties. + */ +export function Preference(props) { + Logger.AssertTrue( + "name" in props && "value" in props, + "Preference must have both name and value" + ); + + this.name = props.name; + this.value = props.value; +} + +/** + * Preference instance methods + */ +Preference.prototype = { + /** + * Modify + * + * Sets the value of the preference this.name to this.value. + * Throws on error. + * + * @return nothing + */ + Modify() { + // Determine if this pref is actually something Weave even looks at. + let weavepref = WEAVE_PREF_PREFIX + this.name; + try { + let syncPref = Services.prefs.getBoolPref(weavepref); + if (!syncPref) { + Services.prefs.setBoolPref(weavepref, true); + } + } catch (e) { + Logger.AssertTrue(false, "Weave doesn't sync pref " + this.name); + } + + // Modify the pref; throw an exception if the pref type is different + // than the value type specified in the test. + let prefType = Services.prefs.getPrefType(this.name); + switch (prefType) { + case Ci.nsIPrefBranch.PREF_INT: + Logger.AssertEqual( + typeof this.value, + "number", + "Wrong type used for preference value" + ); + Services.prefs.setIntPref(this.name, this.value); + break; + case Ci.nsIPrefBranch.PREF_STRING: + Logger.AssertEqual( + typeof this.value, + "string", + "Wrong type used for preference value" + ); + Services.prefs.setCharPref(this.name, this.value); + break; + case Ci.nsIPrefBranch.PREF_BOOL: + Logger.AssertEqual( + typeof this.value, + "boolean", + "Wrong type used for preference value" + ); + Services.prefs.setBoolPref(this.name, this.value); + break; + } + }, + + /** + * Find + * + * Verifies that the preference this.name has the value + * this.value. Throws on error, or if the pref's type or value + * doesn't match. + * + * @return nothing + */ + Find() { + // Read the pref value. + let value; + try { + let prefType = Services.prefs.getPrefType(this.name); + switch (prefType) { + case Ci.nsIPrefBranch.PREF_INT: + value = Services.prefs.getIntPref(this.name); + break; + case Ci.nsIPrefBranch.PREF_STRING: + value = Services.prefs.getCharPref(this.name); + break; + case Ci.nsIPrefBranch.PREF_BOOL: + value = Services.prefs.getBoolPref(this.name); + break; + } + } catch (e) { + Logger.AssertTrue(false, "Error accessing pref " + this.name); + } + + // Throw an exception if the current and expected values aren't of + // the same type, or don't have the same values. + Logger.AssertEqual( + typeof value, + typeof this.value, + "Value types don't match" + ); + Logger.AssertEqual(value, this.value, "Preference values don't match"); + }, +}; diff --git a/services/sync/tps/extensions/tps/resource/modules/tabs.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/tabs.sys.mjs new file mode 100644 index 0000000000..8ea8f3b780 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/tabs.sys.mjs @@ -0,0 +1,92 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + ChromeUtils.import() and acts as a singleton. + Only the following listed symbols will exposed on import, and only when + and where imported. */ + +import { Weave } from "resource://services-sync/main.sys.mjs"; + +import { Logger } from "resource://tps/logger.sys.mjs"; + +// Unfortunately, due to where TPS is run, we can't directly reuse the logic from +// BrowserTestUtils.sys.mjs. Moreover, we can't resolve the URI it loads the content +// frame script from ("chrome://mochikit/content/tests/BrowserTestUtils/content-utils.js"), +// hence the hackiness here and in BrowserTabs.Add. +Services.mm.loadFrameScript( + "data:application/javascript;charset=utf-8," + + encodeURIComponent(` + addEventListener("load", function(event) { + let subframe = event.target != content.document; + sendAsyncMessage("tps:loadEvent", {subframe: subframe, url: event.target.documentURI}); + }, true)`), + true, + true +); + +export var BrowserTabs = { + /** + * Add + * + * Opens a new tab in the current browser window for the + * given uri. Rejects on error. + * + * @param uri The uri to load in the new tab + * @return Promise + */ + async Add(uri) { + let mainWindow = Services.wm.getMostRecentWindow("navigator:browser"); + let browser = mainWindow.gBrowser; + let newtab = browser.addTrustedTab(uri); + + // Wait for the tab to load. + await new Promise(resolve => { + let mm = browser.ownerGlobal.messageManager; + mm.addMessageListener("tps:loadEvent", function onLoad(msg) { + mm.removeMessageListener("tps:loadEvent", onLoad); + resolve(); + }); + }); + + browser.selectedTab = newtab; + }, + + /** + * Find + * + * Finds the specified uri and title in Weave's list of remote tabs + * for the specified profile. + * + * @param uri The uri of the tab to find + * @param title The page title of the tab to find + * @param profile The profile to search for tabs + * @return true if the specified tab could be found, otherwise false + */ + async Find(uri, title, profile) { + // Find the uri in Weave's list of tabs for the given profile. + let tabEngine = Weave.Service.engineManager.get("tabs"); + for (let client of Weave.Service.clientsEngine.remoteClients) { + let tabClients = await tabEngine.getAllClients(); + let tabClient = tabClients.find(x => x.id === client.id); + if (!tabClient || !tabClient.tabs) { + continue; + } + for (let key in tabClient.tabs) { + let tab = tabClient.tabs[key]; + let weaveTabUrl = tab.urlHistory[0]; + if (uri == weaveTabUrl && profile == client.name) { + if (title == undefined || title == tab.title) { + return true; + } + } + } + Logger.logInfo( + `Dumping tabs for ${client.name}...\n` + + JSON.stringify(tabClient.tabs, null, 2) + ); + } + return false; + }, +}; diff --git a/services/sync/tps/extensions/tps/resource/modules/windows.sys.mjs b/services/sync/tps/extensions/tps/resource/modules/windows.sys.mjs new file mode 100644 index 0000000000..b0798b9031 --- /dev/null +++ b/services/sync/tps/extensions/tps/resource/modules/windows.sys.mjs @@ -0,0 +1,32 @@ +/* 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/. */ + +/* This is a JavaScript module (JSM) to be imported via + ChromeUtils.import() and acts as a singleton. + Only the following listed symbols will exposed on import, and only when + and where imported. */ + +export var BrowserWindows = { + /** + * Add + * + * Opens a new window. Throws on error. + * + * @param aPrivate The private option. + * @return nothing + */ + Add(aPrivate, fn) { + return new Promise(resolve => { + let mainWindow = Services.wm.getMostRecentWindow("navigator:browser"); + let win = mainWindow.OpenBrowserWindow({ private: aPrivate }); + win.addEventListener( + "load", + function () { + resolve(win); + }, + { once: true } + ); + }); + }, +}; |