Source code

Revision control

Copy as Markdown

Other Tools

/* 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:
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { Utils } from "resource://services-sync/util.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
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.
ChromeUtils.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.
ChromeUtils.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 => {
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;