diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /services/sync/modules/bookmark_validator.js | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'services/sync/modules/bookmark_validator.js')
-rw-r--r-- | services/sync/modules/bookmark_validator.js | 784 |
1 files changed, 784 insertions, 0 deletions
diff --git a/services/sync/modules/bookmark_validator.js b/services/sync/modules/bookmark_validator.js new file mode 100644 index 000000000..2a94ba043 --- /dev/null +++ b/services/sync/modules/bookmark_validator.js @@ -0,0 +1,784 @@ +/* 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 Cu = Components.utils; + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/PlacesSyncUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + + +this.EXPORTED_SYMBOLS = ["BookmarkValidator", "BookmarkProblemData"]; + +const LEFT_PANE_ROOT_ANNO = "PlacesOrganizer/OrganizerFolder"; +const LEFT_PANE_QUERY_ANNO = "PlacesOrganizer/OrganizerQuery"; + +// Indicates if a local bookmark tree node should be excluded from syncing. +function isNodeIgnored(treeNode) { + return treeNode.annos && treeNode.annos.some(anno => anno.name == LEFT_PANE_ROOT_ANNO || + anno.name == LEFT_PANE_QUERY_ANNO); +} +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 + */ +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(this, "SYNCED_ROOTS", () => [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, +]); + +class BookmarkValidator { + + _followQueries(recordMap) { + for (let [guid, entry] of recordMap) { + if (entry.type !== "query" && (!entry.bmkUri || !entry.bmkUri.startsWith("place:"))) { + continue; + } + // Might be worth trying to parse the place: query instead so that this + // works "automatically" with things like aboutsync. + let queryNodeParent = PlacesUtils.getFolderContents(entry, false, true); + if (!queryNodeParent || !queryNodeParent.root.hasChildren) { + continue; + } + queryNodeParent = queryNodeParent.root; + let queryNode = null; + let numSiblings = 0; + let containerWasOpen = queryNodeParent.containerOpen; + queryNodeParent.containerOpen = true; + try { + try { + numSiblings = queryNodeParent.childCount; + } catch (e) { + // This throws when we can't actually get the children. This is the + // case for history containers, tag queries, ... + continue; + } + for (let i = 0; i < numSiblings && !queryNode; ++i) { + let child = queryNodeParent.getChild(i); + if (child && child.bookmarkGuid && child.bookmarkGuid === guid) { + queryNode = child; + } + } + } finally { + queryNodeParent.containerOpen = containerWasOpen; + } + if (!queryNode) { + continue; + } + + let concreteId = PlacesUtils.getConcreteItemGuid(queryNode); + if (!concreteId) { + continue; + } + let concreteItem = recordMap.get(concreteId); + if (!concreteItem) { + continue; + } + entry.concrete = concreteItem; + } + } + + createClientRecordsFromTree(clientTree) { + // Iterate over the treeNode, converting it to something more similar to what + // the server stores. + let records = []; + let recordsByGuid = new Map(); + let syncedRoots = SYNCED_ROOTS; + function traverse(treeNode, synced) { + if (!synced) { + synced = syncedRoots.includes(treeNode.guid); + } else if (isNodeIgnored(treeNode)) { + synced = false; + } + let guid = PlacesSyncUtils.bookmarks.guidToSyncId(treeNode.guid); + let itemType = 'item'; + treeNode.ignored = !synced; + treeNode.id = guid; + switch (treeNode.type) { + case PlacesUtils.TYPE_X_MOZ_PLACE: + let query = null; + if (treeNode.annos && treeNode.uri.startsWith("place:")) { + query = treeNode.annos.find(({name}) => + name === PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO); + } + if (query && query.value) { + itemType = 'query'; + } else { + itemType = 'bookmark'; + } + break; + case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: + let isLivemark = false; + if (treeNode.annos) { + for (let anno of treeNode.annos) { + if (anno.name === PlacesUtils.LMANNO_FEEDURI) { + isLivemark = true; + treeNode.feedUri = anno.value; + } else if (anno.name === PlacesUtils.LMANNO_SITEURI) { + isLivemark = true; + treeNode.siteUri = anno.value; + } + } + } + itemType = isLivemark ? "livemark" : "folder"; + break; + case 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); + // We want to use the "real" guid here. + recordsByGuid.set(treeNode.guid, treeNode); + if (treeNode.type === 'folder') { + treeNode.childGUIDs = []; + if (!treeNode.children) { + treeNode.children = []; + } + for (let child of treeNode.children) { + traverse(child, synced); + child.parent = treeNode; + child.parentid = guid; + treeNode.childGUIDs.push(child.guid); + } + } + } + traverse(clientTree, false); + clientTree.id = 'places'; + this._followQueries(recordsByGuid); + 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, loadInSidebar + * - 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. + */ + inspectServerRecords(serverRecords) { + let deletedItemIds = new Set(); + let idToRecord = new Map(); + let deletedRecords = []; + + let folders = []; + let problems = []; + + let problemData = new BookmarkProblemData(); + + let resultRecords = []; + + for (let record of serverRecords) { + if (!record.id) { + ++problemData.missingIDs; + continue; + } + if (record.deleted) { + deletedItemIds.add(record.id); + } else { + if (idToRecord.has(record.id)) { + problemData.duplicates.push(record.id); + continue; + } + } + idToRecord.set(record.id, record); + + if (record.children) { + 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) { + continue; + } + // Otherwise we mark it as an error and still try to resolve the children + problemData.childrenOnNonFolder.push(record.id); + } + folders.push(record); + + if (new Set(record.children).size !== record.children.length) { + problemData.duplicateChildren.push(record.id) + } + + // The children array stores special guids as their local guid values, + // e.g. 'menu________' instead of 'menu', but all other parts of the + // serverside bookmark info stores it as the special value ('menu'). + record.childGUIDs = record.children; + record.children = record.children.map(childID => { + return PlacesSyncUtils.bookmarks.guidToSyncId(childID); + }); + } + } + + for (let deletedId of deletedItemIds) { + let record = idToRecord.get(deletedId); + if (record && !record.isDeleted) { + deletedRecords.push(record); + record.isDeleted = true; + } + } + + let root = idToRecord.get('places'); + + if (!root) { + // Fabricate a root. We want to remember that it's fake so that we can + // avoid complaining about stuff like it missing it's childGUIDs later. + root = { id: 'places', children: [], type: 'folder', title: '', fake: true }; + resultRecords.push(root); + idToRecord.set('places', root); + } else { + problemData.rootOnServer = true; + } + + // Build the tree, find orphans, and record most problems having to do with + // the tree structure. + for (let [id, record] of idToRecord) { + if (record === root) { + continue; + } + + if (record.isDeleted) { + continue; + } + + let parentID = record.parentid; + if (!parentID) { + problemData.orphans.push({id: record.id, parent: parentID}); + continue; + } + + let parent = idToRecord.get(parentID); + if (!parent) { + problemData.orphans.push({id: record.id, parent: parentID}); + continue; + } + + if (parent.type !== 'folder') { + problemData.parentNotFolder.push(record.id); + if (!parent.children) { + parent.children = []; + } + if (!parent.childGUIDs) { + parent.childGUIDs = []; + } + } + + if (!record.isDeleted) { + resultRecords.push(record); + } + + record.parent = parent; + if (parent !== root || problemData.rootOnServer) { + let childIndex = parent.children.indexOf(id); + if (childIndex < 0) { + problemData.parentChildMismatches.push({parent: parent.id, child: record.id}); + } else { + parent.children[childIndex] = record; + } + } else { + parent.children.push(record); + } + + if (parent.isDeleted && !record.isDeleted) { + problemData.deletedParents.push(record.id); + } + + // 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. + } + + // Check that we aren't missing any children. + for (let folder of folders) { + folder.unfilteredChildren = folder.children; + folder.children = []; + for (let ci = 0; ci < folder.unfilteredChildren.length; ++ci) { + let child = folder.unfilteredChildren[ci]; + let childObject; + if (typeof child == "string") { + // This can happen the parent refers to a child that has a different + // parentid, or if it refers to a missing or deleted child. It shouldn't + // be possible with totally valid bookmarks. + childObject = idToRecord.get(child); + if (!childObject) { + problemData.missingChildren.push({parent: folder.id, child}); + } else { + folder.unfilteredChildren[ci] = childObject; + if (childObject.isDeleted) { + problemData.deletedChildren.push({ parent: folder.id, child }); + } + } + } else { + childObject = child; + } + + if (!childObject) { + continue; + } + + if (childObject.parentid === folder.id) { + folder.children.push(childObject); + continue; + } + + // The child is very probably in multiple `children` arrays -- + // see if we already have a problem record about it. + let currentProblemRecord = problemData.multipleParents.find(pr => + pr.child === child); + + if (currentProblemRecord) { + currentProblemRecord.parents.push(folder.id); + continue; + } + + let otherParent = idToRecord.get(childObject.parentid); + // it's really an ... orphan ... sort of. + if (!otherParent) { + // if we never end up adding to this parent's list, we filter it out after this loop. + problemData.multipleParents.push({ + child, + parents: [folder.id] + }); + if (!problemData.orphans.some(r => r.id === child)) { + problemData.orphans.push({ + id: child, + parent: childObject.parentid + }); + } + continue; + } + + if (otherParent.isDeleted) { + if (!problemData.deletedParents.includes(child)) { + problemData.deletedParents.push(child); + } + continue; + } + + if (otherParent.childGUIDs && !otherParent.childGUIDs.includes(child)) { + if (!problemData.parentChildMismatches.some(r => r.child === child)) { + // Might not be possible to get here. + problemData.parentChildMismatches.push({ child, parent: folder.id }); + } + } + + problemData.multipleParents.push({ + child, + parents: [childObject.parentid, folder.id] + }); + } + } + problemData.multipleParents = problemData.multipleParents.filter(record => + record.parents.length >= 2); + + problemData.cycles = this._detectCycles(resultRecords); + + return { + deletedRecords, + records: resultRecords, + problemData, + root, + }; + } + + // helper for inspectServerRecords + _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 traverse = 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.concrete) { + children.push(node.concrete); + } + if (children) { + pathLookup.add(node); + currentPath.push(node); + for (let child of children) { + traverse(child); + } + currentPath.pop(); + pathLookup.delete(node); + } + }; + for (let record of records) { + if (!seenEver.has(record)) { + traverse(record); + } + } + + return cycles; + } + + // Perform client-side sanity checking that doesn't involve server data + _validateClient(problemData, clientRecords) { + problemData.clientCycles = this._detectCycles(clientRecords); + for (let rootGUID of SYNCED_ROOTS) { + let record = clientRecords.find(record => + record.guid === rootGUID); + if (!record || record.parentid !== "places") { + problemData.badClientRoots.push(rootGUID); + } + } + } + + /** + * 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. + */ + compareServerWithClient(serverRecords, clientTree) { + + let clientRecords = this.createClientRecordsFromTree(clientTree); + let inspectionInfo = this.inspectServerRecords(serverRecords); + inspectionInfo.clientRecords = clientRecords; + + // Mainly do this to remove deleted items and normalize child guids. + serverRecords = inspectionInfo.records; + let problemData = inspectionInfo.problemData; + + this._validateClient(problemData, clientRecords); + + let matches = []; + + let allRecords = new Map(); + let serverDeletedLookup = new Set(inspectionInfo.deletedRecords.map(r => r.id)); + + for (let sr of serverRecords) { + if (sr.fake) { + continue; + } + allRecords.set(sr.id, {client: null, server: sr}); + } + + for (let cr of clientRecords) { + let unified = allRecords.get(cr.id); + if (!unified) { + allRecords.set(cr.id, {client: cr, server: null}); + } else { + unified.client = cr; + } + } + + + for (let [id, {client, server}] of allRecords) { + if (!client && server) { + problemData.clientMissing.push(id); + continue; + } + if (!server && client) { + if (serverDeletedLookup.has(id)) { + problemData.serverDeleted.push(id); + } else if (!client.ignored && client.id != "places") { + problemData.serverMissing.push(id); + } + continue; + } + if (server && client && client.ignored) { + problemData.serverUnexpected.push(id); + } + let differences = []; + let structuralDifferences = []; + + // 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 (!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 || []; + let sl = server.tags || []; + if (cl.length !== sl.length || !cl.every((tag, i) => sl.indexOf(tag) >= 0)) { + differences.push('tags'); + } + } + + let sameType = client.type === server.type; + if (!sameType) { + if (server.type === "query" && client.type === "bookmark" && client.bmkUri.startsWith("place:")) { + sameType = true; + } + } + + + if (!sameType) { + differences.push('type'); + } else { + switch (server.type) { + case 'bookmark': + case 'query': + if (server.bmkUri !== client.bmkUri) { + differences.push('bmkUri'); + } + 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' && !problemData.rootOnServer) { + // 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 (cl.length !== sl.length || !cl.every((id, i) => sl[i] === id)) { + structuralDifferences.push('childGUIDs'); + } + } + break; + } + } + + if (differences.length) { + problemData.differences.push({id, differences}); + } + if (structuralDifferences.length) { + problemData.structuralDifferences.push({ id, differences: structuralDifferences }); + } + } + return inspectionInfo; + } + + _getServerState(engine) { + let collection = engine.itemSource(); + let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name); + collection.full = true; + let items = []; + collection.recordHandler = function(item) { + item.decrypt(collectionKey); + items.push(item.cleartext); + }; + let resp = collection.getBatched(); + if (!resp.success) { + throw resp; + } + return items; + } + + validate(engine) { + let self = this; + return Task.spawn(function*() { + let start = Date.now(); + let clientTree = yield PlacesUtils.promiseBookmarksTree("", { + includeItemIds: true + }); + let serverState = self._getServerState(engine); + let serverRecordCount = serverState.length; + let result = self.compareServerWithClient(serverState, clientTree); + let end = Date.now(); + let duration = end-start; + return { + duration, + version: self.version, + problems: result.problemData, + recordCount: serverRecordCount + }; + }); + } + +}; + +BookmarkValidator.prototype.version = BOOKMARK_VALIDATOR_VERSION; + |