places/bookmark_sync/
engine.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use super::incoming::IncomingApplicator;
6use super::record::{
7    BookmarkItemRecord, BookmarkRecord, BookmarkRecordId, FolderRecord, QueryRecord,
8    SeparatorRecord,
9};
10use super::{SyncedBookmarkKind, SyncedBookmarkValidity};
11use crate::db::{GlobalChangeCounterTracker, PlacesDb, SharedPlacesDb};
12use crate::error::*;
13use crate::frecency::{calculate_frecency, DEFAULT_FRECENCY_SETTINGS};
14use crate::storage::{
15    bookmarks::{
16        bookmark_sync::{create_synced_bookmark_roots, reset},
17        BookmarkRootGuid,
18    },
19    delete_pending_temp_tables, get_meta, put_meta,
20};
21use crate::types::{BookmarkType, SyncStatus, UnknownFields};
22use dogear::{
23    self, AbortSignal, CompletionOps, Content, Item, MergedRoot, TelemetryEvent, Tree, UploadItem,
24    UploadTombstone,
25};
26use interrupt_support::SqlInterruptScope;
27use rusqlite::ErrorCode;
28use rusqlite::Row;
29use sql_support::ConnExt;
30use std::cell::RefCell;
31use std::collections::HashMap;
32use std::fmt;
33use std::sync::Arc;
34use sync15::bso::{IncomingBso, OutgoingBso};
35use sync15::engine::{CollSyncIds, CollectionRequest, EngineSyncAssociation, SyncEngine};
36use sync15::{telemetry, CollectionName, ServerTimestamp};
37use sync_guid::Guid as SyncGuid;
38use types::Timestamp;
39pub const LAST_SYNC_META_KEY: &str = "bookmarks_last_sync_time";
40// Note that all engines in this crate should use a *different* meta key
41// for the global sync ID, because engines are reset individually.
42pub const GLOBAL_SYNCID_META_KEY: &str = "bookmarks_global_sync_id";
43pub const COLLECTION_SYNCID_META_KEY: &str = "bookmarks_sync_id";
44pub const COLLECTION_NAME: &str = "bookmarks";
45
46/// The maximum number of URLs for which to recalculate frecencies at once.
47/// This is a trade-off between write efficiency and transaction time: higher
48/// maximums mean fewer write statements, but longer transactions, possibly
49/// blocking writes from other connections.
50const MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK: usize = 400;
51
52/// Adapts an interruptee to a Dogear abort signal.
53struct MergeInterruptee<'a>(&'a SqlInterruptScope);
54
55impl AbortSignal for MergeInterruptee<'_> {
56    #[inline]
57    fn aborted(&self) -> bool {
58        self.0.was_interrupted()
59    }
60}
61
62fn stage_incoming(
63    db: &PlacesDb,
64    scope: &SqlInterruptScope,
65    inbound: Vec<IncomingBso>,
66    incoming_telemetry: &mut telemetry::EngineIncoming,
67) -> Result<()> {
68    let mut tx = db.begin_transaction()?;
69
70    let applicator = IncomingApplicator::new(db);
71
72    for incoming in inbound {
73        applicator.apply_bso(incoming)?;
74        incoming_telemetry.applied(1);
75        if tx.should_commit() {
76            // Trigger frecency updates for all new origins.
77            debug!("Updating origins for new synced URLs since last commit");
78            delete_pending_temp_tables(db)?;
79        }
80        tx.maybe_commit()?;
81        scope.err_if_interrupted()?;
82    }
83
84    debug!("Updating origins for new synced URLs in last chunk");
85    delete_pending_temp_tables(db)?;
86
87    tx.commit()?;
88    Ok(())
89}
90
91fn db_has_changes(db: &PlacesDb) -> Result<bool> {
92    // In the first subquery, we check incoming items with needsMerge = true
93    // except the tombstones who don't correspond to any local bookmark because
94    // we don't store them yet, hence never "merged" (see bug 1343103).
95    let sql = format!(
96        "SELECT
97            EXISTS (
98                SELECT 1
99                FROM moz_bookmarks_synced v
100                LEFT JOIN moz_bookmarks b ON v.guid = b.guid
101                WHERE v.needsMerge AND
102                (NOT v.isDeleted OR b.guid NOT NULL)
103            ) OR EXISTS (
104                WITH RECURSIVE
105                {}
106                SELECT 1
107                FROM localItems
108                WHERE syncChangeCounter > 0
109            ) OR EXISTS (
110                SELECT 1
111                FROM moz_bookmarks_deleted
112            )
113         AS hasChanges",
114        LocalItemsFragment("localItems")
115    );
116    Ok(db
117        .try_query_row(
118            &sql,
119            [],
120            |row| -> rusqlite::Result<_> { row.get::<_, bool>(0) },
121            false,
122        )?
123        .unwrap_or(false))
124}
125
126/// Builds a temporary table with the merge states of all nodes in the merged
127/// tree, then updates the local tree to match the merged tree.
128///
129/// Conceptually, we examine the merge state of each item, and either leave the
130/// item unchanged, upload the local side, apply the remote side, or apply and
131/// then reupload the remote side with a new structure.
132fn update_local_items_in_places(
133    db: &PlacesDb,
134    scope: &SqlInterruptScope,
135    now: Timestamp,
136    ops: &CompletionOps<'_>,
137) -> Result<()> {
138    // Build a table of new and updated items.
139    debug!(
140        "Staging apply {} remote item ops",
141        ops.apply_remote_items.len()
142    );
143    sql_support::each_sized_chunk(
144        &ops.apply_remote_items,
145        sql_support::default_max_variable_number() / 3,
146        |chunk, _| -> Result<()> {
147            // CTEs in `WITH` clauses aren't indexed, so this query needs a
148            // full table scan on `ops`. But that's okay; a separate temp
149            // table for ops would also need a full scan. Note that we need
150            // both the local _and_ remote GUIDs here, because we haven't
151            // changed the local GUIDs yet.
152            let sql = format!(
153                "WITH ops(mergedGuid, localGuid, remoteGuid, remoteType,
154                          level) AS (
155                     VALUES {ops}
156                 )
157                 INSERT INTO itemsToApply(mergedGuid, localId, remoteId,
158                                          remoteGuid, newLevel, newKind,
159                                          localDateAdded, remoteDateAdded,
160                                          lastModified, oldTitle, newTitle,
161                                          oldPlaceId, newPlaceId,
162                                          newKeyword)
163                 SELECT n.mergedGuid, b.id, v.id,
164                        v.guid, n.level, n.remoteType,
165                        b.dateAdded, v.dateAdded,
166                        v.serverModified, b.title, v.title,
167                        b.fk, v.placeId,
168                        v.keyword
169                 FROM ops n
170                 JOIN moz_bookmarks_synced v ON v.guid = n.remoteGuid
171                 LEFT JOIN moz_bookmarks b ON b.guid = n.localGuid",
172                ops = sql_support::repeat_display(chunk.len(), ",", |index, f| {
173                    let op = &chunk[index];
174                    write!(
175                        f,
176                        "(?, ?, ?, {}, {})",
177                        SyncedBookmarkKind::from(op.remote_node().kind) as u8,
178                        op.level
179                    )
180                }),
181            );
182
183            // We can't avoid allocating here, since we're binding four
184            // parameters per descendant. Rust's `SliceConcatExt::concat`
185            // is semantically equivalent, but requires a second allocation,
186            // which we _can_ avoid by writing this out.
187            let mut params = Vec::with_capacity(chunk.len() * 3);
188            for op in chunk.iter() {
189                scope.err_if_interrupted()?;
190
191                let merged_guid = op.merged_node.guid.as_str();
192                params.push(Some(merged_guid));
193
194                let local_guid = op
195                    .merged_node
196                    .merge_state
197                    .local_node()
198                    .map(|node| node.guid.as_str());
199                params.push(local_guid);
200
201                let remote_guid = op.remote_node().guid.as_str();
202                params.push(Some(remote_guid));
203            }
204
205            db.execute(&sql, rusqlite::params_from_iter(params))?;
206            Ok(())
207        },
208    )?;
209
210    debug!("Staging {} change GUID ops", ops.change_guids.len());
211    sql_support::each_sized_chunk(
212        &ops.change_guids,
213        sql_support::default_max_variable_number() / 2,
214        |chunk, _| -> Result<()> {
215            let sql = format!(
216                "INSERT INTO changeGuidOps(localGuid, mergedGuid,
217                                           syncStatus, level, lastModified)
218                 VALUES {}",
219                sql_support::repeat_display(chunk.len(), ",", |index, f| {
220                    let op = &chunk[index];
221                    // If only the local GUID changed, the item was deduped, so we
222                    // can mark it as syncing. Otherwise, we changed an invalid
223                    // GUID locally or remotely, so we leave its original sync
224                    // status in place until we've uploaded it.
225                    let sync_status = if op.merged_node.remote_guid_changed() {
226                        None
227                    } else {
228                        Some(SyncStatus::Normal as u8)
229                    };
230                    write!(
231                        f,
232                        "(?, ?, {}, {}, {})",
233                        NullableFragment(sync_status),
234                        op.level,
235                        now
236                    )
237                }),
238            );
239
240            let mut params = Vec::with_capacity(chunk.len() * 2);
241            for op in chunk.iter() {
242                scope.err_if_interrupted()?;
243
244                let local_guid = op.local_node().guid.as_str();
245                params.push(local_guid);
246
247                let merged_guid = op.merged_node.guid.as_str();
248                params.push(merged_guid);
249            }
250
251            db.execute(&sql, rusqlite::params_from_iter(params))?;
252            Ok(())
253        },
254    )?;
255
256    debug!(
257        "Staging apply {} new local structure ops",
258        ops.apply_new_local_structure.len()
259    );
260    sql_support::each_sized_chunk(
261        &ops.apply_new_local_structure,
262        sql_support::default_max_variable_number() / 2,
263        |chunk, _| -> Result<()> {
264            let sql = format!(
265                "INSERT INTO applyNewLocalStructureOps(
266                     mergedGuid, mergedParentGuid, position, level
267                 )
268                 VALUES {}",
269                sql_support::repeat_display(chunk.len(), ",", |index, f| {
270                    let op = &chunk[index];
271                    write!(f, "(?, ?, {}, {})", op.position, op.level)
272                }),
273            );
274
275            let mut params = Vec::with_capacity(chunk.len() * 2);
276            for op in chunk.iter() {
277                scope.err_if_interrupted()?;
278
279                let merged_guid = op.merged_node.guid.as_str();
280                params.push(merged_guid);
281
282                let merged_parent_guid = op.merged_parent_node.guid.as_str();
283                params.push(merged_parent_guid);
284            }
285
286            db.execute(&sql, rusqlite::params_from_iter(params))?;
287            Ok(())
288        },
289    )?;
290
291    debug!(
292        "Removing {} tombstones for revived items",
293        ops.delete_local_tombstones.len()
294    );
295    sql_support::each_chunk_mapped(
296        &ops.delete_local_tombstones,
297        |op| op.guid().as_str(),
298        |chunk, _| -> Result<()> {
299            scope.err_if_interrupted()?;
300            db.execute(
301                &format!(
302                    "DELETE FROM moz_bookmarks_deleted
303                     WHERE guid IN ({})",
304                    sql_support::repeat_sql_vars(chunk.len())
305                ),
306                rusqlite::params_from_iter(chunk),
307            )?;
308            Ok(())
309        },
310    )?;
311
312    debug!(
313        "Inserting {} new tombstones for non-syncable and invalid items",
314        ops.insert_local_tombstones.len()
315    );
316    sql_support::each_chunk_mapped(
317        &ops.insert_local_tombstones,
318        |op| op.remote_node().guid.as_str().to_owned(),
319        |chunk, _| -> Result<()> {
320            scope.err_if_interrupted()?;
321            db.execute(
322                &format!(
323                    "INSERT INTO moz_bookmarks_deleted(guid, dateRemoved)
324                     VALUES {}",
325                    sql_support::repeat_display(chunk.len(), ",", |_, f| write!(f, "(?, {})", now)),
326                ),
327                rusqlite::params_from_iter(chunk),
328            )?;
329            Ok(())
330        },
331    )?;
332
333    debug!(
334        "Flag frecencies for {} removed bookmark URLs as stale",
335        ops.delete_local_items.len()
336    );
337    sql_support::each_chunk_mapped(
338        &ops.delete_local_items,
339        |op| op.local_node().guid.as_str().to_owned(),
340        |chunk, _| -> Result<()> {
341            scope.err_if_interrupted()?;
342            db.execute(
343                &format!(
344                    "REPLACE INTO moz_places_stale_frecencies(
345                         place_id, stale_at
346                     )
347                     SELECT b.fk, {now}
348                     FROM moz_bookmarks b
349                     WHERE b.guid IN ({vars})
350                     AND b.fk NOT NULL",
351                    now = now,
352                    vars = sql_support::repeat_sql_vars(chunk.len())
353                ),
354                rusqlite::params_from_iter(chunk),
355            )?;
356            Ok(())
357        },
358    )?;
359
360    debug!(
361        "Removing {} deleted items from Places",
362        ops.delete_local_items.len()
363    );
364    sql_support::each_chunk_mapped(
365        &ops.delete_local_items,
366        |op| op.local_node().guid.as_str().to_owned(),
367        |chunk, _| -> Result<()> {
368            scope.err_if_interrupted()?;
369            db.execute(
370                &format!(
371                    "DELETE FROM moz_bookmarks
372                     WHERE guid IN ({})",
373                    sql_support::repeat_sql_vars(chunk.len())
374                ),
375                rusqlite::params_from_iter(chunk),
376            )?;
377            Ok(())
378        },
379    )?;
380
381    debug!("Changing GUIDs");
382    scope.err_if_interrupted()?;
383    db.execute_batch("DELETE FROM changeGuidOps")?;
384
385    debug!("Applying remote items");
386    apply_remote_items(db, scope, now)?;
387
388    // Fires the `applyNewLocalStructure` trigger.
389    debug!("Applying new local structure");
390    scope.err_if_interrupted()?;
391    db.execute_batch("DELETE FROM applyNewLocalStructureOps")?;
392
393    // Similar to the check in apply_remote_items, however we do a post check
394    // to see if dogear was unable to fix up the issue
395    let orphaned_count: i64 = db.query_row(
396        "WITH RECURSIVE orphans(id) AS (
397           SELECT b.id
398           FROM moz_bookmarks b
399           WHERE b.parent IS NOT NULL
400             AND NOT EXISTS (
401               SELECT 1 FROM moz_bookmarks p WHERE p.id = b.parent
402             )
403           UNION
404           SELECT c.id
405           FROM moz_bookmarks c
406           JOIN orphans o ON c.parent = o.id
407         )
408         SELECT COUNT(*) FROM orphans;",
409        [],
410        |row| row.get(0),
411    )?;
412
413    if orphaned_count > 0 {
414        warn!("Found {} orphaned bookmarks after sync", orphaned_count);
415        error_support::report_error!(
416            "places-sync-bookmarks-orphaned",
417            "found local orphaned bookmarks after we applied new local structure ops: {}",
418            orphaned_count,
419        );
420    }
421
422    debug!(
423        "Resetting change counters for {} items that shouldn't be uploaded",
424        ops.set_local_merged.len()
425    );
426    sql_support::each_chunk_mapped(
427        &ops.set_local_merged,
428        |op| op.merged_node.guid.as_str(),
429        |chunk, _| -> Result<()> {
430            scope.err_if_interrupted()?;
431            db.execute(
432                &format!(
433                    "UPDATE moz_bookmarks SET
434                         syncChangeCounter = 0
435                     WHERE guid IN ({})",
436                    sql_support::repeat_sql_vars(chunk.len()),
437                ),
438                rusqlite::params_from_iter(chunk),
439            )?;
440            Ok(())
441        },
442    )?;
443
444    debug!(
445        "Bumping change counters for {} items that should be uploaded",
446        ops.set_local_unmerged.len()
447    );
448    sql_support::each_chunk_mapped(
449        &ops.set_local_unmerged,
450        |op| op.merged_node.guid.as_str(),
451        |chunk, _| -> Result<()> {
452            scope.err_if_interrupted()?;
453            db.execute(
454                &format!(
455                    "UPDATE moz_bookmarks SET
456                         syncChangeCounter = 1
457                     WHERE guid IN ({})",
458                    sql_support::repeat_sql_vars(chunk.len()),
459                ),
460                rusqlite::params_from_iter(chunk),
461            )?;
462            Ok(())
463        },
464    )?;
465
466    debug!(
467        "Flagging applied {} remote items as merged",
468        ops.set_remote_merged.len()
469    );
470    sql_support::each_chunk_mapped(
471        &ops.set_remote_merged,
472        |op| op.guid().as_str(),
473        |chunk, _| -> Result<()> {
474            scope.err_if_interrupted()?;
475            db.execute(
476                &format!(
477                    "UPDATE moz_bookmarks_synced SET
478                         needsMerge = 0
479                     WHERE guid IN ({})",
480                    sql_support::repeat_sql_vars(chunk.len()),
481                ),
482                rusqlite::params_from_iter(chunk),
483            )?;
484            Ok(())
485        },
486    )?;
487
488    Ok(())
489}
490
491fn apply_remote_items(db: &PlacesDb, scope: &SqlInterruptScope, now: Timestamp) -> Result<()> {
492    // Remove all keywords from old and new URLs, and remove new keywords
493    // from all existing URLs. The `NOT NULL` conditions are important; they
494    // ensure that SQLite uses our partial indexes on `itemsToApply`,
495    // instead of a table scan.
496    debug!("Removing old keywords");
497    scope.err_if_interrupted()?;
498    db.execute_batch(
499        "DELETE FROM moz_keywords
500         WHERE place_id IN (SELECT oldPlaceId FROM itemsToApply
501                            WHERE oldPlaceId NOT NULL) OR
502               place_id IN (SELECT newPlaceId FROM itemsToApply
503                            WHERE newPlaceId NOT NULL) OR
504               keyword IN (SELECT newKeyword FROM itemsToApply
505                           WHERE newKeyword NOT NULL)",
506    )?;
507
508    debug!("Removing old tags");
509    scope.err_if_interrupted()?;
510    db.execute_batch(
511        "DELETE FROM moz_tags_relation
512         WHERE place_id IN (SELECT oldPlaceId FROM itemsToApply
513                            WHERE oldPlaceId NOT NULL) OR
514               place_id IN (SELECT newPlaceId FROM itemsToApply
515                            WHERE newPlaceId NOT NULL)",
516    )?;
517
518    // Due to bug 1935797, we try to add additional logging on what exact
519    // guids are colliding as it could shed light on what's going on
520    debug!("Checking for potential GUID collisions before upserting items");
521    let collision_check_sql = "
522        SELECT ia.localId, ia.mergedGuid, ia.remoteGuid, b.id, b.guid
523        FROM itemsToApply ia
524        JOIN moz_bookmarks b ON ia.mergedGuid = b.guid
525        WHERE (ia.localId IS NULL OR ia.localId != b.id)
526    ";
527
528    let potential_collisions: Vec<(Option<i64>, String, String, i64, String)> = db
529        .prepare(collision_check_sql)?
530        .query_map([], |row| {
531            let ia_local_id: Option<i64> = row.get(0)?;
532            let ia_merged_guid: String = row.get(1)?;
533            let ia_remote_guid: String = row.get(2)?;
534            let bmk_id: i64 = row.get(3)?;
535            let bmk_guid: String = row.get(4)?;
536            Ok((
537                ia_local_id,
538                ia_merged_guid,
539                ia_remote_guid,
540                bmk_id,
541                bmk_guid,
542            ))
543        })?
544        .filter_map(|entry| entry.ok())
545        .collect();
546
547    if !potential_collisions.is_empty() {
548        // Log details about the collisions
549        for (ia_local_id, ia_merged_guid, ia_remote_guid, bmk_id, bmk_guid) in &potential_collisions
550        {
551            error_support::breadcrumb!(
552                "Found GUID collision: ia_localId={:?}, ia_mergedGuid={}, ia_remoteGuid={}, mb_id={}, mb_guid={}",
553                ia_local_id,
554                ia_merged_guid,
555                ia_remote_guid,
556                bmk_id,
557                bmk_guid
558            );
559        }
560    }
561
562    // Due to bug 1935797, we need to check if any users have any
563    // undetected orphaned bookmarks and report them
564    let orphaned_count: i64 = db.query_row(
565        "WITH RECURSIVE orphans(id) AS (
566           SELECT b.id
567           FROM moz_bookmarks b
568           WHERE b.parent IS NOT NULL
569             AND NOT EXISTS (
570               SELECT 1 FROM moz_bookmarks p WHERE p.id = b.parent
571             )
572           UNION
573           SELECT c.id
574           FROM moz_bookmarks c
575           JOIN orphans o ON c.parent = o.id
576         )
577         SELECT COUNT(*) FROM orphans;",
578        [],
579        |row| row.get(0),
580    )?;
581
582    if orphaned_count > 0 {
583        warn!("Found {} orphaned bookmarks during sync", orphaned_count);
584        error_support::breadcrumb!(
585            "places-sync-bookmarks-orphaned: found local orphans before upsert {}",
586            orphaned_count
587        );
588    }
589
590    // Insert and update items, temporarily using the Places root for new
591    // items' parent IDs, and -1 for positions. We'll fix these up later,
592    // when we apply the new local structure. This `INSERT` is a full table
593    // scan on `itemsToApply`. The no-op `WHERE` clause is necessary to
594    // avoid a parsing ambiguity.
595    debug!("Upserting new items");
596    let upsert_sql = format!(
597        "INSERT INTO moz_bookmarks(id, guid, parent,
598                                   position, type, fk, title,
599                                   dateAdded,
600                                   lastModified,
601                                   syncStatus, syncChangeCounter)
602         SELECT localId, mergedGuid, (SELECT id FROM moz_bookmarks
603                                      WHERE guid = '{root_guid}'),
604                -1, {type_fragment}, newPlaceId, newTitle,
605                /* Pick the older of the local and remote date added. We'll
606                   weakly reupload any items with an older local date. */
607                MIN(IFNULL(localDateAdded, remoteDateAdded), remoteDateAdded),
608                /* The last modified date should always be newer than the date
609                   added, so we pick the newer of the two here. */
610                MAX(lastModified, remoteDateAdded),
611                {sync_status}, 0
612         FROM itemsToApply
613         WHERE 1
614         ON CONFLICT(id) DO UPDATE SET
615           title = excluded.title,
616           dateAdded = excluded.dateAdded,
617           lastModified = excluded.lastModified,
618           fk = excluded.fk,
619           syncStatus = {sync_status}
620       /* Due to bug 1935797, we found scenarios where users had bookmarks with GUIDs that matched
621        * incoming records BUT for one reason or another dogear doesn't believe it exists locally
622        * This handles the case where we try to insert a new bookmark with a GUID that already exists,
623        * updating the existing record instead of failing with a constraint violation.
624        * Usually the above conflict will catch most of these scenarios and there's no issue of
625        * any dupes being added here since users that hit this before would've just failed the bookmark sync
626        */
627        ON CONFLICT(guid) DO UPDATE SET
628           title = excluded.title,
629           dateAdded = excluded.dateAdded,
630           lastModified = excluded.lastModified,
631           fk = excluded.fk,
632           syncStatus = {sync_status}",
633        root_guid = BookmarkRootGuid::Root.as_guid().as_str(),
634        type_fragment = ItemTypeFragment("newKind"),
635        sync_status = SyncStatus::Normal as u8,
636    );
637
638    scope.err_if_interrupted()?;
639    let result = db.execute_batch(&upsert_sql);
640
641    // In trying to debug bug 1935797 - relaxing the trigger caused a spike on
642    // guid collisions, we want to report on this during the upsert to see
643    // if we can discern any obvious signs
644    if let Err(rusqlite::Error::SqliteFailure(e, _)) = &result {
645        if e.code == ErrorCode::ConstraintViolation {
646            error_support::report_error!(
647                "places-sync-bookmarks-constraint-violation",
648                "Hit a constraint violation {:?}",
649                result
650            );
651        }
652    }
653    // Return the original result
654    result?;
655
656    debug!("Flagging frecencies for recalculation");
657    scope.err_if_interrupted()?;
658    db.execute_batch(&format!(
659        "REPLACE INTO moz_places_stale_frecencies(place_id, stale_at)
660         SELECT oldPlaceId, {now} FROM itemsToApply
661         WHERE newKind = {bookmark_kind} AND (
662                   oldPlaceId IS NULL <> newPlaceId IS NULL OR
663                   oldPlaceId <> newPlaceId
664               )
665         UNION ALL
666         SELECT newPlaceId, {now} FROM itemsToApply
667         WHERE newKind = {bookmark_kind} AND (
668                   newPlaceId IS NULL <> oldPlaceId IS NULL OR
669                   newPlaceId <> oldPlaceId
670               )",
671        now = now,
672        bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
673    ))?;
674
675    debug!("Inserting new keywords for new URLs");
676    scope.err_if_interrupted()?;
677    db.execute_batch(
678        "INSERT OR IGNORE INTO moz_keywords(keyword, place_id)
679         SELECT newKeyword, newPlaceId
680         FROM itemsToApply
681         WHERE newKeyword NOT NULL",
682    )?;
683
684    debug!("Inserting new tags for new URLs");
685    scope.err_if_interrupted()?;
686    db.execute_batch(
687        "INSERT OR IGNORE INTO moz_tags_relation(tag_id, place_id)
688         SELECT r.tagId, n.newPlaceId
689         FROM itemsToApply n
690         JOIN moz_bookmarks_synced_tag_relation r ON r.itemId = n.remoteId",
691    )?;
692
693    Ok(())
694}
695
696/// Stores a snapshot of all locally changed items in a temporary table for
697/// upload. This is called from within the merge transaction, to ensure that
698/// changes made during the sync don't cause us to upload inconsistent
699/// records.
700///
701/// Conceptually, `itemsToUpload` is a transient "view" of locally changed
702/// items. The local change counter is the persistent record of items that
703/// we need to upload, so, if upload is interrupted or fails, we'll stage
704/// the items again on the next sync.
705fn stage_items_to_upload(
706    db: &PlacesDb,
707    scope: &SqlInterruptScope,
708    upload_items: &[UploadItem<'_>],
709    upload_tombstones: &[UploadTombstone<'_>],
710) -> Result<()> {
711    debug!("Cleaning up staged items left from last sync");
712    scope.err_if_interrupted()?;
713    db.execute_batch("DELETE FROM itemsToUpload")?;
714
715    // Stage remotely changed items with older local creation dates. These are
716    // tracked "weakly": if the upload is interrupted or fails, we won't
717    // reupload the record on the next sync.
718    debug!("Staging items with older local dates added");
719    scope.err_if_interrupted()?;
720    db.execute_batch(&format!(
721        "INSERT OR IGNORE INTO itemsToUpload(id, guid, syncChangeCounter,
722                                             parentGuid, parentTitle, dateAdded,
723                                             kind, title, placeId, url,
724                                             keyword, position)
725         {}
726         JOIN itemsToApply n ON n.mergedGuid = b.guid
727         WHERE n.localDateAdded < n.remoteDateAdded",
728        UploadItemsFragment("b")
729    ))?;
730
731    debug!(
732        "Staging {} remaining locally changed items for upload",
733        upload_items.len()
734    );
735    sql_support::each_chunk_mapped(
736        upload_items,
737        |op| op.merged_node.guid.as_str(),
738        |chunk, _| -> Result<()> {
739            let sql = format!(
740                "INSERT OR IGNORE INTO itemsToUpload(id, guid, syncChangeCounter,
741                                                  parentGuid, parentTitle,
742                                                  dateAdded, kind, title,
743                                                  placeId, url, keyword,
744                                                  position)
745                 {upload_items_fragment}
746                 WHERE b.guid IN ({vars})",
747                vars = sql_support::repeat_sql_vars(chunk.len()),
748                upload_items_fragment = UploadItemsFragment("b")
749            );
750
751            db.execute(&sql, rusqlite::params_from_iter(chunk))?;
752            Ok(())
753        },
754    )?;
755
756    // Record the child GUIDs of locally changed folders, which we use to
757    // populate the `children` array in the record.
758    debug!("Staging structure to upload");
759    scope.err_if_interrupted()?;
760    db.execute_batch(
761        "INSERT INTO structureToUpload(guid, parentId, position)
762         SELECT b.guid, b.parent, b.position
763         FROM moz_bookmarks b
764         JOIN itemsToUpload o ON o.id = b.parent",
765    )?;
766
767    // Stage tags for outgoing bookmarks.
768    debug!("Staging tags to upload");
769    scope.err_if_interrupted()?;
770    db.execute_batch(
771        "INSERT INTO tagsToUpload(id, tag)
772         SELECT o.id, t.tag
773         FROM itemsToUpload o
774         JOIN moz_tags_relation r ON r.place_id = o.placeId
775         JOIN moz_tags t ON t.id = r.tag_id",
776    )?;
777
778    // Finally, stage tombstones for deleted items.
779    debug!("Staging {} tombstones to upload", upload_tombstones.len());
780    sql_support::each_chunk_mapped(
781        upload_tombstones,
782        |op| op.guid().as_str(),
783        |chunk, _| -> Result<()> {
784            scope.err_if_interrupted()?;
785            db.execute(
786                &format!(
787                    "INSERT OR IGNORE INTO itemsToUpload(
788                     guid, syncChangeCounter, isDeleted
789                 )
790                 VALUES {}",
791                    sql_support::repeat_display(chunk.len(), ",", |_, f| write!(f, "(?, 1, 1)")),
792                ),
793                rusqlite::params_from_iter(chunk),
794            )?;
795            Ok(())
796        },
797    )?;
798
799    Ok(())
800}
801
802/// Inflates Sync records for all staged outgoing items.
803fn fetch_outgoing_records(db: &PlacesDb, scope: &SqlInterruptScope) -> Result<Vec<OutgoingBso>> {
804    let mut changes = Vec::new();
805    let mut child_record_ids_by_local_parent_id: HashMap<i64, Vec<BookmarkRecordId>> =
806        HashMap::new();
807    let mut tags_by_local_id: HashMap<i64, Vec<String>> = HashMap::new();
808
809    let mut stmt = db.prepare(
810        "SELECT parentId, guid FROM structureToUpload
811         ORDER BY parentId, position",
812    )?;
813    let mut results = stmt.query([])?;
814    while let Some(row) = results.next()? {
815        scope.err_if_interrupted()?;
816        let local_parent_id = row.get::<_, i64>("parentId")?;
817        let child_guid = row.get::<_, SyncGuid>("guid")?;
818        let child_record_ids = child_record_ids_by_local_parent_id
819            .entry(local_parent_id)
820            .or_default();
821        child_record_ids.push(child_guid.into());
822    }
823
824    let mut stmt = db.prepare("SELECT id, tag FROM tagsToUpload")?;
825    let mut results = stmt.query([])?;
826    while let Some(row) = results.next()? {
827        scope.err_if_interrupted()?;
828        let local_id = row.get::<_, i64>("id")?;
829        let tag = row.get::<_, String>("tag")?;
830        let tags = tags_by_local_id.entry(local_id).or_default();
831        tags.push(tag);
832    }
833
834    let mut stmt = db.prepare(
835        "SELECT i.id, i.syncChangeCounter, i.guid, i.isDeleted, i.kind, i.keyword,
836                i.url, IFNULL(i.title, '') AS title, i.position, i.parentGuid,
837                IFNULL(i.parentTitle, '') AS parentTitle, i.dateAdded, m.unknownFields
838         FROM itemsToUpload i
839         LEFT JOIN moz_bookmarks_synced m ON i.guid == m.guid
840         ",
841    )?;
842    let mut results = stmt.query([])?;
843    while let Some(row) = results.next()? {
844        scope.err_if_interrupted()?;
845        let guid = row.get::<_, SyncGuid>("guid")?;
846        let is_deleted = row.get::<_, bool>("isDeleted")?;
847        if is_deleted {
848            changes.push(OutgoingBso::new_tombstone(
849                BookmarkRecordId::from(guid).as_guid().clone().into(),
850            ));
851            continue;
852        }
853        let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
854        let parent_title = row.get::<_, String>("parentTitle")?;
855        let date_added = row.get::<_, i64>("dateAdded")?;
856        let unknown_fields = match row.get::<_, Option<String>>("unknownFields")? {
857            None => UnknownFields::new(),
858            Some(s) => serde_json::from_str(&s)?,
859        };
860        let record: BookmarkItemRecord = match SyncedBookmarkKind::from_u8(row.get("kind")?)? {
861            SyncedBookmarkKind::Bookmark => {
862                let local_id = row.get::<_, i64>("id")?;
863                let title = row.get::<_, String>("title")?;
864                let url = row.get::<_, String>("url")?;
865                BookmarkRecord {
866                    record_id: guid.into(),
867                    parent_record_id: Some(parent_guid.into()),
868                    parent_title: Some(parent_title),
869                    date_added: Some(date_added),
870                    has_dupe: true,
871                    title: Some(title),
872                    url: Some(url),
873                    keyword: row.get::<_, Option<String>>("keyword")?,
874                    tags: tags_by_local_id.remove(&local_id).unwrap_or_default(),
875                    unknown_fields,
876                }
877                .into()
878            }
879            SyncedBookmarkKind::Query => {
880                let title = row.get::<_, String>("title")?;
881                let url = row.get::<_, String>("url")?;
882                QueryRecord {
883                    record_id: guid.into(),
884                    parent_record_id: Some(parent_guid.into()),
885                    parent_title: Some(parent_title),
886                    date_added: Some(date_added),
887                    has_dupe: true,
888                    title: Some(title),
889                    url: Some(url),
890                    tag_folder_name: None,
891                    unknown_fields,
892                }
893                .into()
894            }
895            SyncedBookmarkKind::Folder => {
896                let title = row.get::<_, String>("title")?;
897                let local_id = row.get::<_, i64>("id")?;
898                let children = child_record_ids_by_local_parent_id
899                    .remove(&local_id)
900                    .unwrap_or_default();
901                FolderRecord {
902                    record_id: guid.into(),
903                    parent_record_id: Some(parent_guid.into()),
904                    parent_title: Some(parent_title),
905                    date_added: Some(date_added),
906                    has_dupe: true,
907                    title: Some(title),
908                    children,
909                    unknown_fields,
910                }
911                .into()
912            }
913            SyncedBookmarkKind::Livemark => continue,
914            SyncedBookmarkKind::Separator => {
915                let position = row.get::<_, i64>("position")?;
916                SeparatorRecord {
917                    record_id: guid.into(),
918                    parent_record_id: Some(parent_guid.into()),
919                    parent_title: Some(parent_title),
920                    date_added: Some(date_added),
921                    has_dupe: true,
922                    position: Some(position),
923                    unknown_fields,
924                }
925                .into()
926            }
927        };
928        changes.push(OutgoingBso::from_content_with_id(record)?);
929    }
930
931    Ok(changes)
932}
933
934/// Decrements the change counter, updates the sync status, and cleans up
935/// tombstones for successfully synced items. Sync calls this method at the
936/// end of each bookmark sync.
937fn push_synced_items(
938    db: &PlacesDb,
939    scope: &SqlInterruptScope,
940    uploaded_at: ServerTimestamp,
941    records_synced: Vec<SyncGuid>,
942) -> Result<()> {
943    // Flag all successfully synced records as uploaded. This `UPDATE` fires
944    // the `pushUploadedChanges` trigger, which updates local change
945    // counters and writes the items back to the synced bookmarks table.
946    let mut tx = db.begin_transaction()?;
947
948    let guids = records_synced
949        .into_iter()
950        .map(|id| BookmarkRecordId::from_payload_id(id).into())
951        .collect::<Vec<SyncGuid>>();
952    sql_support::each_chunk(&guids, |chunk, _| -> Result<()> {
953        db.execute(
954            &format!(
955                "UPDATE itemsToUpload SET
956                     uploadedAt = {uploaded_at}
957                     WHERE guid IN ({values})",
958                uploaded_at = uploaded_at.as_millis(),
959                values = sql_support::repeat_sql_values(chunk.len())
960            ),
961            rusqlite::params_from_iter(chunk),
962        )?;
963        tx.maybe_commit()?;
964        scope.err_if_interrupted()?;
965        Ok(())
966    })?;
967
968    // Fast-forward the last sync time, so that we don't download the
969    // records we just uploaded on the next sync.
970    put_meta(db, LAST_SYNC_META_KEY, &uploaded_at.as_millis())?;
971
972    // Clean up.
973    db.execute_batch("DELETE FROM itemsToUpload")?;
974    tx.commit()?;
975
976    Ok(())
977}
978
979pub(crate) fn update_frecencies(db: &PlacesDb, scope: &SqlInterruptScope) -> Result<()> {
980    let mut tx = db.begin_transaction()?;
981
982    let mut frecencies = Vec::with_capacity(MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK);
983    loop {
984        let sql = format!(
985            "SELECT place_id FROM moz_places_stale_frecencies
986             ORDER BY stale_at DESC
987             LIMIT {}",
988            MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK
989        );
990        let mut stmt = db.prepare_maybe_cached(&sql, true)?;
991        let mut results = stmt.query([])?;
992        while let Some(row) = results.next()? {
993            let place_id = row.get("place_id")?;
994            // Frecency recalculation runs several statements, so check to
995            // make sure we aren't interrupted before each calculation.
996            scope.err_if_interrupted()?;
997            let frecency =
998                calculate_frecency(db, &DEFAULT_FRECENCY_SETTINGS, place_id, Some(false))?;
999            frecencies.push((place_id, frecency));
1000        }
1001        if frecencies.is_empty() {
1002            break;
1003        }
1004
1005        // Update all frecencies in one fell swoop...
1006        db.execute_batch(&format!(
1007            "WITH frecencies(id, frecency) AS (
1008               VALUES {}
1009             )
1010             UPDATE moz_places SET
1011               frecency = (SELECT frecency FROM frecencies f
1012                           WHERE f.id = id)
1013             WHERE id IN (SELECT f.id FROM frecencies f)",
1014            sql_support::repeat_display(frecencies.len(), ",", |index, f| {
1015                let (id, frecency) = frecencies[index];
1016                write!(f, "({}, {})", id, frecency)
1017            })
1018        ))?;
1019        tx.maybe_commit()?;
1020        scope.err_if_interrupted()?;
1021
1022        // ...And remove them from the stale table.
1023        db.execute_batch(&format!(
1024            "DELETE FROM moz_places_stale_frecencies
1025             WHERE place_id IN ({})",
1026            sql_support::repeat_display(frecencies.len(), ",", |index, f| {
1027                let (id, _) = frecencies[index];
1028                write!(f, "{}", id)
1029            })
1030        ))?;
1031        tx.maybe_commit()?;
1032        scope.err_if_interrupted()?;
1033
1034        // If the query returned fewer URLs than the maximum, we're done.
1035        // Otherwise, we might have more, so clear the ones we just
1036        // recalculated and fetch the next chunk.
1037        if frecencies.len() < MAX_FRECENCIES_TO_RECALCULATE_PER_CHUNK {
1038            break;
1039        }
1040        frecencies.clear();
1041    }
1042
1043    tx.commit()?;
1044
1045    Ok(())
1046}
1047
1048// Short-lived struct that's constructed each sync
1049pub struct BookmarksSyncEngine {
1050    db: Arc<SharedPlacesDb>,
1051    // Pub so that it can be used by the PlacesApi methods.  Once all syncing goes through the
1052    // `SyncManager` we should be able to make this private.
1053    pub(crate) scope: SqlInterruptScope,
1054}
1055
1056impl BookmarksSyncEngine {
1057    pub fn new(db: Arc<SharedPlacesDb>) -> Result<Self> {
1058        Ok(Self {
1059            scope: db.begin_interrupt_scope()?,
1060            db,
1061        })
1062    }
1063}
1064
1065impl SyncEngine for BookmarksSyncEngine {
1066    #[inline]
1067    fn collection_name(&self) -> CollectionName {
1068        COLLECTION_NAME.into()
1069    }
1070
1071    fn stage_incoming(
1072        &self,
1073        inbound: Vec<IncomingBso>,
1074        telem: &mut telemetry::Engine,
1075    ) -> anyhow::Result<()> {
1076        let conn = self.db.lock();
1077        // Stage all incoming items.
1078        let mut incoming_telemetry = telemetry::EngineIncoming::new();
1079        stage_incoming(&conn, &self.scope, inbound, &mut incoming_telemetry)?;
1080        telem.incoming(incoming_telemetry);
1081        Ok(())
1082    }
1083
1084    fn apply(
1085        &self,
1086        timestamp: ServerTimestamp,
1087        telem: &mut telemetry::Engine,
1088    ) -> anyhow::Result<Vec<OutgoingBso>> {
1089        let conn = self.db.lock();
1090        // write the timestamp now, so if we are interrupted merging or
1091        // creating outgoing changesets we don't need to re-apply the same
1092        // records.
1093        put_meta(&conn, LAST_SYNC_META_KEY, &timestamp.as_millis())?;
1094
1095        // Merge.
1096        let mut merger = Merger::with_telemetry(&conn, &self.scope, timestamp, telem);
1097        merger.merge()?;
1098        Ok(fetch_outgoing_records(&conn, &self.scope)?)
1099    }
1100
1101    fn set_uploaded(
1102        &self,
1103        new_timestamp: ServerTimestamp,
1104        ids: Vec<SyncGuid>,
1105    ) -> anyhow::Result<()> {
1106        let conn = self.db.lock();
1107        push_synced_items(&conn, &self.scope, new_timestamp, ids)?;
1108        Ok(update_frecencies(&conn, &self.scope)?)
1109    }
1110
1111    fn sync_finished(&self) -> anyhow::Result<()> {
1112        let conn = self.db.lock();
1113        conn.pragma_update(None, "wal_checkpoint", "PASSIVE")?;
1114        Ok(())
1115    }
1116
1117    fn get_collection_request(
1118        &self,
1119        server_timestamp: ServerTimestamp,
1120    ) -> anyhow::Result<Option<CollectionRequest>> {
1121        let conn = self.db.lock();
1122        let since =
1123            ServerTimestamp(get_meta::<i64>(&conn, LAST_SYNC_META_KEY)?.unwrap_or_default());
1124        Ok(if since == server_timestamp {
1125            None
1126        } else {
1127            Some(
1128                CollectionRequest::new(self.collection_name())
1129                    .full()
1130                    .newer_than(since),
1131            )
1132        })
1133    }
1134
1135    fn get_sync_assoc(&self) -> anyhow::Result<EngineSyncAssociation> {
1136        let conn = self.db.lock();
1137        let global = get_meta(&conn, GLOBAL_SYNCID_META_KEY)?;
1138        let coll = get_meta(&conn, COLLECTION_SYNCID_META_KEY)?;
1139        Ok(if let (Some(global), Some(coll)) = (global, coll) {
1140            EngineSyncAssociation::Connected(CollSyncIds { global, coll })
1141        } else {
1142            EngineSyncAssociation::Disconnected
1143        })
1144    }
1145
1146    fn reset(&self, assoc: &EngineSyncAssociation) -> anyhow::Result<()> {
1147        let conn = self.db.lock();
1148        reset(&conn, assoc)?;
1149        Ok(())
1150    }
1151
1152    /// Erases all local items. Unlike `reset`, this keeps all synced items
1153    /// until the next sync, when they will be replaced with tombstones. This
1154    /// also preserves the sync ID and last sync time.
1155    ///
1156    /// Conceptually, the next sync will merge an empty local tree, and a full
1157    /// remote tree.
1158    fn wipe(&self) -> anyhow::Result<()> {
1159        let conn = self.db.lock();
1160        let tx = conn.begin_transaction()?;
1161        let sql = format!(
1162            "INSERT INTO moz_bookmarks_deleted(guid, dateRemoved)
1163             SELECT guid, now()
1164             FROM moz_bookmarks
1165             WHERE guid NOT IN {roots} AND
1166                   syncStatus = {sync_status};
1167
1168             UPDATE moz_bookmarks SET
1169               syncChangeCounter = syncChangeCounter + 1
1170             WHERE guid IN {roots};
1171
1172             DELETE FROM moz_bookmarks
1173             WHERE guid NOT IN {roots};",
1174            roots = RootsFragment(&[
1175                BookmarkRootGuid::Root,
1176                BookmarkRootGuid::Menu,
1177                BookmarkRootGuid::Mobile,
1178                BookmarkRootGuid::Toolbar,
1179                BookmarkRootGuid::Unfiled
1180            ]),
1181            sync_status = SyncStatus::Normal as u8
1182        );
1183        conn.execute_batch(&sql)?;
1184        create_synced_bookmark_roots(&conn)?;
1185        tx.commit()?;
1186        Ok(())
1187    }
1188}
1189
1190#[derive(Default)]
1191struct Driver {
1192    validation: RefCell<telemetry::Validation>,
1193}
1194
1195impl dogear::Driver for Driver {
1196    fn generate_new_guid(&self, _invalid_guid: &dogear::Guid) -> dogear::Result<dogear::Guid> {
1197        Ok(SyncGuid::random().as_str().into())
1198    }
1199
1200    fn record_telemetry_event(&self, event: TelemetryEvent) {
1201        // Record validation telemetry for remote trees.
1202        if let TelemetryEvent::FetchRemoteTree(stats) = event {
1203            self.validation
1204                .borrow_mut()
1205                .problem("orphans", stats.problems.orphans)
1206                .problem("misparentedRoots", stats.problems.misparented_roots)
1207                .problem(
1208                    "multipleParents",
1209                    stats.problems.multiple_parents_by_children,
1210                )
1211                .problem("missingParents", stats.problems.missing_parent_guids)
1212                .problem("nonFolderParents", stats.problems.non_folder_parent_guids)
1213                .problem(
1214                    "parentChildDisagreements",
1215                    stats.problems.parent_child_disagreements,
1216                )
1217                .problem("missingChildren", stats.problems.missing_children);
1218        }
1219    }
1220}
1221
1222// The "merger", which is just a thin wrapper for dogear.
1223pub(crate) struct Merger<'a> {
1224    db: &'a PlacesDb,
1225    scope: &'a SqlInterruptScope,
1226    remote_time: ServerTimestamp,
1227    local_time: Timestamp,
1228    // Used for where the merger is not the one which should be managing the
1229    // transaction, e.g. in the case of bookmarks import. The only impact this has
1230    // is on the `apply()` function. Always false unless the caller explicitly
1231    // turns it on, to avoid accidentally enabling unintentionally.
1232    external_transaction: bool,
1233    telem: Option<&'a mut telemetry::Engine>,
1234    // Allows us to abort applying the result of the merge if the local tree
1235    // changed since we fetched it.
1236    global_change_tracker: GlobalChangeCounterTracker,
1237}
1238
1239impl<'a> Merger<'a> {
1240    #[cfg(test)]
1241    pub(crate) fn new(
1242        db: &'a PlacesDb,
1243        scope: &'a SqlInterruptScope,
1244        remote_time: ServerTimestamp,
1245    ) -> Self {
1246        Self {
1247            db,
1248            scope,
1249            remote_time,
1250            local_time: Timestamp::now(),
1251            external_transaction: false,
1252            telem: None,
1253            global_change_tracker: db.global_bookmark_change_tracker(),
1254        }
1255    }
1256
1257    pub(crate) fn with_telemetry(
1258        db: &'a PlacesDb,
1259        scope: &'a SqlInterruptScope,
1260        remote_time: ServerTimestamp,
1261        telem: &'a mut telemetry::Engine,
1262    ) -> Self {
1263        Self {
1264            db,
1265            scope,
1266            remote_time,
1267            local_time: Timestamp::now(),
1268            external_transaction: false,
1269            telem: Some(telem),
1270            global_change_tracker: db.global_bookmark_change_tracker(),
1271        }
1272    }
1273
1274    #[cfg(test)]
1275    fn with_localtime(
1276        db: &'a PlacesDb,
1277        scope: &'a SqlInterruptScope,
1278        remote_time: ServerTimestamp,
1279        local_time: Timestamp,
1280    ) -> Self {
1281        Self {
1282            db,
1283            scope,
1284            remote_time,
1285            local_time,
1286            external_transaction: false,
1287            telem: None,
1288            global_change_tracker: db.global_bookmark_change_tracker(),
1289        }
1290    }
1291
1292    pub(crate) fn merge(&mut self) -> Result<()> {
1293        use dogear::Store;
1294        if !db_has_changes(self.db)? {
1295            return Ok(());
1296        }
1297        // Merge and stage outgoing items via dogear.
1298        let driver = Driver::default();
1299        self.prepare()?;
1300        let result = self.merge_with_driver(&driver, &MergeInterruptee(self.scope));
1301        debug!("merge completed: {:?}", result);
1302
1303        // Record telemetry in all cases, even if the merge fails.
1304        if let Some(ref mut telem) = self.telem {
1305            telem.validation(driver.validation.into_inner());
1306        }
1307        result
1308    }
1309
1310    /// Prepares synced bookmarks for merging.
1311    fn prepare(&self) -> Result<()> {
1312        // Sync and Fennec associate keywords with bookmarks, and don't sync
1313        // POST data; Rust Places associates them with URLs, and also doesn't
1314        // support POST data; Desktop associates keywords with (URL, POST data)
1315        // pairs, and multiple bookmarks may have the same URL.
1316        //
1317        // When a keyword changes, clients should reupload all bookmarks with
1318        // the affected URL (bug 1328737). Just in case, we flag any synced
1319        // bookmarks that have different keywords for the same URL, or the same
1320        // keyword for different URLs, for reupload.
1321        self.scope.err_if_interrupted()?;
1322        debug!("Flagging bookmarks with mismatched keywords for reupload");
1323        let sql = format!(
1324            "UPDATE moz_bookmarks_synced SET
1325               validity = {reupload}
1326             WHERE validity = {valid} AND (
1327               placeId IN (
1328                 /* Same URL, different keywords. `COUNT` ignores NULLs, so
1329                    we need to count them separately. This handles cases where
1330                    a keyword was removed from one, but not all bookmarks with
1331                    the same URL. */
1332                 SELECT placeId FROM moz_bookmarks_synced
1333                 GROUP BY placeId
1334                 HAVING COUNT(DISTINCT keyword) +
1335                        COUNT(DISTINCT CASE WHEN keyword IS NULL
1336                                       THEN 1 END) > 1
1337               ) OR keyword IN (
1338                 /* Different URLs, same keyword. Bookmarks with keywords but
1339                    without URLs are already invalid, so we don't need to handle
1340                    NULLs here. */
1341                 SELECT keyword FROM moz_bookmarks_synced
1342                 WHERE keyword NOT NULL
1343                 GROUP BY keyword
1344                 HAVING COUNT(DISTINCT placeId) > 1
1345               )
1346             )",
1347            reupload = SyncedBookmarkValidity::Reupload as u8,
1348            valid = SyncedBookmarkValidity::Valid as u8,
1349        );
1350        self.db.execute_batch(&sql)?;
1351
1352        // Like keywords, Sync associates tags with bookmarks, but Places
1353        // associates them with URLs. This means multiple bookmarks with the
1354        // same URL should have the same tags. In practice, different tags for
1355        // bookmarks with the same URL are some of the most common validation
1356        // errors we see.
1357        //
1358        // Unlike keywords, the relationship between URLs and tags in many-many:
1359        // multiple URLs can have the same tag, and a URL can have multiple
1360        // tags. So, to find mismatches, we need to compare the tags for each
1361        // URL with the tags for each item.
1362        //
1363        // We could fetch both lists of tags, sort them, and then compare them.
1364        // But there's a trick here: we're only interested in whether the tags
1365        // _match_, not the tags themselves. So we sum the tag IDs!
1366        //
1367        // This has two advantages: we don't have to sort IDs, since addition is
1368        // commutative, and we can compare two integers much more efficiently
1369        // than two string lists! If a bookmark has mismatched tags, the sum of
1370        // its tag IDs in `tagsByItemId` won't match the sum in `tagsByPlaceId`,
1371        // and we'll flag the item for reupload.
1372        self.scope.err_if_interrupted()?;
1373        debug!("Flagging bookmarks with mismatched tags for reupload");
1374        let sql = format!(
1375            "WITH
1376             tagsByPlaceId(placeId, tagIds) AS (
1377                 /* For multiple bookmarks with the same URL, each group will
1378                    have one tag per bookmark. So, if bookmarks A1, A2, and A3
1379                    have the same URL A with tag T, T will be in the group three
1380                    times. But we only want to count each tag once per URL, so
1381                    we use `SUM(DISTINCT)`. */
1382                 SELECT v.placeId, SUM(DISTINCT t.tagId)
1383                 FROM moz_bookmarks_synced v
1384                 JOIN moz_bookmarks_synced_tag_relation t ON t.itemId = v.id
1385                 WHERE v.placeId NOT NULL
1386                 GROUP BY v.placeId
1387             ),
1388             tagsByItemId(itemId, tagIds) AS (
1389                 /* But here, we can use a plain `SUM`, since we're grouping by
1390                    item ID, and an item can't have duplicate tags thanks to the
1391                    primary key on the relation table. */
1392                 SELECT t.itemId, SUM(t.tagId)
1393                 FROM moz_bookmarks_synced_tag_relation t
1394                 GROUP BY t.itemId
1395             )
1396             UPDATE moz_bookmarks_synced SET
1397                 validity = {reupload}
1398             WHERE validity = {valid} AND id IN (
1399                 SELECT v.id FROM moz_bookmarks_synced v
1400                 JOIN tagsByPlaceId u ON v.placeId = u.placeId
1401                 /* This left join is important: if A1 has tags and A2 doesn't,
1402                    we want to flag A2 for reupload. */
1403                 LEFT JOIN tagsByItemId t ON t.itemId = v.id
1404                 /* Unlike `<>`, `IS NOT` compares NULLs. */
1405                 WHERE t.tagIds IS NOT u.tagIds
1406             )",
1407            reupload = SyncedBookmarkValidity::Reupload as u8,
1408            valid = SyncedBookmarkValidity::Valid as u8,
1409        );
1410        self.db.execute_batch(&sql)?;
1411
1412        Ok(())
1413    }
1414
1415    /// Creates a local tree item from a row in the `localItems` CTE.
1416    fn local_row_to_item(&self, row: &Row<'_>) -> Result<(Item, Option<Content>)> {
1417        let guid = row.get::<_, SyncGuid>("guid")?;
1418        let url_href = row.get::<_, Option<String>>("url")?;
1419        let kind = match row.get::<_, BookmarkType>("type")? {
1420            BookmarkType::Bookmark => match url_href.as_ref() {
1421                Some(u) if u.starts_with("place:") => SyncedBookmarkKind::Query,
1422                _ => SyncedBookmarkKind::Bookmark,
1423            },
1424            BookmarkType::Folder => SyncedBookmarkKind::Folder,
1425            BookmarkType::Separator => SyncedBookmarkKind::Separator,
1426        };
1427        let mut item = Item::new(guid.as_str().into(), kind.into());
1428        // Note that this doesn't account for local clock skew.
1429        let age = self
1430            .local_time
1431            .duration_since(row.get::<_, Timestamp>("localModified")?)
1432            .unwrap_or_default();
1433        item.age = age.as_secs() as i64 * 1000 + i64::from(age.subsec_millis());
1434        item.needs_merge = row.get::<_, u32>("syncChangeCounter")? > 0;
1435
1436        let content = if item.guid == dogear::ROOT_GUID {
1437            None
1438        } else {
1439            match row.get::<_, SyncStatus>("syncStatus")? {
1440                SyncStatus::Normal => None,
1441                _ => match kind {
1442                    SyncedBookmarkKind::Bookmark | SyncedBookmarkKind::Query => {
1443                        let title = row.get::<_, String>("title")?;
1444                        url_href.map(|url_href| Content::Bookmark { title, url_href })
1445                    }
1446                    SyncedBookmarkKind::Folder | SyncedBookmarkKind::Livemark => {
1447                        let title = row.get::<_, String>("title")?;
1448                        Some(Content::Folder { title })
1449                    }
1450                    SyncedBookmarkKind::Separator => Some(Content::Separator),
1451                },
1452            }
1453        };
1454
1455        Ok((item, content))
1456    }
1457
1458    /// Creates a remote tree item from a row in `moz_bookmarks_synced`.
1459    fn remote_row_to_item(&self, row: &Row<'_>) -> Result<(Item, Option<Content>)> {
1460        let guid = row.get::<_, SyncGuid>("guid")?;
1461        let kind = SyncedBookmarkKind::from_u8(row.get("kind")?)?;
1462        let mut item = Item::new(guid.as_str().into(), kind.into());
1463        // note that serverModified in this table is an int with ms, which isn't
1464        // the format of a ServerTimestamp - so we convert it into a number
1465        // of seconds before creating a ServerTimestamp and doing duration_since.
1466        let age = self
1467            .remote_time
1468            .duration_since(ServerTimestamp(row.get::<_, i64>("serverModified")?))
1469            .unwrap_or_default();
1470        item.age = age.as_secs() as i64 * 1000 + i64::from(age.subsec_millis());
1471        item.needs_merge = row.get("needsMerge")?;
1472        item.validity = SyncedBookmarkValidity::from_u8(row.get("validity")?)?.into();
1473
1474        let content = if item.guid == dogear::ROOT_GUID || !item.needs_merge {
1475            None
1476        } else {
1477            match kind {
1478                SyncedBookmarkKind::Bookmark | SyncedBookmarkKind::Query => {
1479                    let title = row.get::<_, String>("title")?;
1480                    let url_href = row.get::<_, Option<String>>("url")?;
1481                    url_href.map(|url_href| Content::Bookmark { title, url_href })
1482                }
1483                SyncedBookmarkKind::Folder | SyncedBookmarkKind::Livemark => {
1484                    let title = row.get::<_, String>("title")?;
1485                    Some(Content::Folder { title })
1486                }
1487                SyncedBookmarkKind::Separator => Some(Content::Separator),
1488            }
1489        };
1490
1491        Ok((item, content))
1492    }
1493}
1494
1495impl dogear::Store for Merger<'_> {
1496    type Ok = ();
1497    type Error = Error;
1498
1499    /// Builds a fully rooted, consistent tree from all local items and
1500    /// tombstones.
1501    fn fetch_local_tree(&self) -> Result<Tree> {
1502        let mut stmt = self.db.prepare(&format!(
1503            "SELECT guid, type, syncChangeCounter, syncStatus,
1504                    lastModified AS localModified,
1505                    NULL AS url
1506             FROM moz_bookmarks
1507             WHERE guid = '{root_guid}'",
1508            root_guid = BookmarkRootGuid::Root.as_guid().as_str(),
1509        ))?;
1510        let mut results = stmt.query([])?;
1511        let mut builder = match results.next()? {
1512            Some(row) => {
1513                let (item, _) = self.local_row_to_item(row)?;
1514                Tree::with_root(item)
1515            }
1516            None => return Err(Error::Corruption(Corruption::InvalidLocalRoots)),
1517        };
1518
1519        // Add items and contents to the builder, keeping track of their
1520        // structure in a separate map. We can't call `p.by_structure(...)`
1521        // after adding the item, because this query might return rows for
1522        // children before their parents. This approach also lets us scan
1523        // `moz_bookmarks` once, using the index on `(b.parent, b.position)`
1524        // to avoid a temp B-tree for the `ORDER BY`.
1525        let mut child_guids_by_parent_guid: HashMap<SyncGuid, Vec<dogear::Guid>> = HashMap::new();
1526        let mut stmt = self.db.prepare(&format!(
1527            "SELECT b.guid, p.guid AS parentGuid, b.type, b.syncChangeCounter,
1528                    b.syncStatus, b.lastModified AS localModified,
1529                    IFNULL(b.title, '') AS title,
1530                    {url_fragment} AS url
1531             FROM moz_bookmarks b
1532             JOIN moz_bookmarks p ON p.id = b.parent
1533             WHERE b.guid <> '{root_guid}'
1534             ORDER BY b.parent, b.position",
1535            url_fragment = UrlOrPlaceIdFragment::PlaceId("b.fk"),
1536            root_guid = BookmarkRootGuid::Root.as_guid().as_str(),
1537        ))?;
1538        let mut results = stmt.query([])?;
1539
1540        while let Some(row) = results.next()? {
1541            self.scope.err_if_interrupted()?;
1542
1543            let (item, content) = self.local_row_to_item(row)?;
1544
1545            let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
1546            child_guids_by_parent_guid
1547                .entry(parent_guid)
1548                .or_default()
1549                .push(item.guid.clone());
1550
1551            let mut p = builder.item(item)?;
1552            if let Some(content) = content {
1553                p.content(content);
1554            }
1555        }
1556
1557        // At this point, we've added entries for all items to the tree, so
1558        // we can add their structure info.
1559        for (parent_guid, child_guids) in &child_guids_by_parent_guid {
1560            for child_guid in child_guids {
1561                self.scope.err_if_interrupted()?;
1562                builder
1563                    .parent_for(child_guid)
1564                    .by_structure(&parent_guid.as_str().into())?;
1565            }
1566        }
1567
1568        // Note tombstones for locally deleted items.
1569        let mut stmt = self.db.prepare("SELECT guid FROM moz_bookmarks_deleted")?;
1570        let mut results = stmt.query([])?;
1571        while let Some(row) = results.next()? {
1572            self.scope.err_if_interrupted()?;
1573            let guid = row.get::<_, SyncGuid>("guid")?;
1574            builder.deletion(guid.as_str().into());
1575        }
1576
1577        let tree = Tree::try_from(builder)?;
1578        Ok(tree)
1579    }
1580
1581    /// Builds a fully rooted tree from all synced items and tombstones.
1582    fn fetch_remote_tree(&self) -> Result<Tree> {
1583        // Unlike the local tree, items and structure are stored separately, so
1584        // we use three separate statements to fetch the root, its descendants,
1585        // and their structure.
1586        let sql = format!(
1587            "SELECT guid, serverModified, kind, needsMerge, validity
1588             FROM moz_bookmarks_synced
1589             WHERE NOT isDeleted AND
1590                   guid = '{root_guid}'",
1591            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1592        );
1593        let mut builder = self
1594            .db
1595            .try_query_row(
1596                &sql,
1597                [],
1598                |row| -> Result<_> {
1599                    let (root, _) = self.remote_row_to_item(row)?;
1600                    Ok(Tree::with_root(root))
1601                },
1602                false,
1603            )?
1604            .ok_or(Error::Corruption(Corruption::InvalidSyncedRoots))?;
1605        builder.reparent_orphans_to(&dogear::UNFILED_GUID);
1606
1607        let sql = format!(
1608            "SELECT v.guid, v.parentGuid, v.serverModified, v.kind,
1609                    IFNULL(v.title, '') AS title, v.needsMerge, v.validity,
1610                    v.isDeleted, {url_fragment} AS url
1611             FROM moz_bookmarks_synced v
1612             WHERE v.guid <> '{root_guid}'
1613             ORDER BY v.guid",
1614            url_fragment = UrlOrPlaceIdFragment::PlaceId("v.placeId"),
1615            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1616        );
1617        let mut stmt = self.db.prepare(&sql)?;
1618        let mut results = stmt.query([])?;
1619        while let Some(row) = results.next()? {
1620            self.scope.err_if_interrupted()?;
1621
1622            let is_deleted = row.get::<_, bool>("isDeleted")?;
1623            if is_deleted {
1624                let needs_merge = row.get::<_, bool>("needsMerge")?;
1625                if !needs_merge {
1626                    // Ignore already-merged tombstones. These aren't persisted
1627                    // locally, so merging them is a no-op.
1628                    continue;
1629                }
1630                let guid = row.get::<_, SyncGuid>("guid")?;
1631                builder.deletion(guid.as_str().into());
1632            } else {
1633                let (item, content) = self.remote_row_to_item(row)?;
1634                let mut p = builder.item(item)?;
1635                if let Some(content) = content {
1636                    p.content(content);
1637                }
1638                if let Some(parent_guid) = row.get::<_, Option<SyncGuid>>("parentGuid")? {
1639                    p.by_parent_guid(parent_guid.as_str().into())?;
1640                }
1641            }
1642        }
1643
1644        // An "endpoint" here means the guid/parent in a `moz_bookmarks_synced_structure` row.
1645        // There are *possibly* schema changes here which would make some of these states unrepresentable.
1646        // eg, no FK relationship between `moz_bookmarks_synced_structure` and `moz_bookmarks_synced(guid)`,
1647        // and a trigger to reject non-parent items. However, other existing code should already be
1648        // enforcing these invariants.
1649        // Working out which shape(s) are occurring in the wild might help us narrow down where the
1650        // defect is.
1651        //
1652        // So we check for and report structure rows with bad endpoints before skipping them.
1653        let folder_kind = SyncedBookmarkKind::Folder as u8;
1654        let root_guid_str = BookmarkRootGuid::Root.as_guid();
1655        let (child_tombstoned, child_absent, parent_tombstoned, parent_absent, non_folder_parent):
1656            (bool, bool, bool, bool, bool) = self.db.query_row(
1657            &format!(
1658                "SELECT
1659                    MAX(child.isDeleted IS 1),
1660                    MAX(child.guid IS NULL),
1661                    MAX(parent.isDeleted IS 1),
1662                    MAX(parent.guid IS NULL),
1663                    MAX(parent.isDeleted IS 0 AND parent.kind <> {folder_kind})
1664                    FROM moz_bookmarks_synced_structure s
1665                    LEFT JOIN moz_bookmarks_synced child  ON child.guid  = s.guid
1666                    LEFT JOIN moz_bookmarks_synced parent ON parent.guid = s.parentGuid
1667                    WHERE s.guid <> '{root_guid}'
1668                ",
1669                root_guid = root_guid_str.as_str(),
1670                folder_kind = folder_kind,
1671            ),
1672            [],
1673            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
1674        )?;
1675        if child_tombstoned {
1676            // The child GUID exists, but is a tombstone.
1677            error_support::report_error!(
1678                "places-bookmarks-structure-child-tombstoned",
1679                "moz_bookmarks_synced_structure has rows whose child guid is tombstoned"
1680            );
1681        }
1682        if child_absent {
1683            // The child GUID does not exist
1684            error_support::report_error!("places-bookmarks-structure-child-absent",
1685                "moz_bookmarks_synced_structure has rows whose child guid is absent from moz_bookmarks_synced");
1686        }
1687        if parent_tombstoned {
1688            // The parent GUID exists, but is a tombstone.
1689            error_support::report_error!(
1690                "places-bookmarks-structure-parent-tombstoned",
1691                "moz_bookmarks_synced_structure has rows whose parent guid is tombstoned"
1692            );
1693        }
1694        if parent_absent {
1695            // The parent GUID does not exist.
1696            // This should be "impossible" because our "ON DELETE CASCADE" on `moz_bookmarks_synced_structure`.
1697            error_support::report_error!("places-bookmarks-structure-parent-absent",
1698                "moz_bookmarks_synced_structure has rows whose parent guid is absent from moz_bookmarks_synced");
1699        }
1700        if non_folder_parent {
1701            // Both sides are OK other than the fact the parent isn't actually a folder.
1702            error_support::report_error!(
1703                "places-bookmarks-structure-non-folder-parent",
1704                "moz_bookmarks_synced_structure has rows whose parent is a non-folder item"
1705            );
1706        }
1707
1708        // Only tell dogear about child/parent relationships where both endpoints are live,
1709        // non-deleted items and the parent is a folder. Orphaned or mis-typed
1710        // structure rows are silently skipped here; the items themselves are still
1711        // inserted into the builder (or recorded as tombstones) by the pass above,
1712        // so any stale structure rows simply produce no structural claim (so dogear
1713        // might end up reparenting to 'unfiled' etc.)
1714        let sql = format!(
1715            "SELECT s.guid, s.parentGuid
1716             FROM moz_bookmarks_synced_structure s
1717             JOIN moz_bookmarks_synced child
1718               ON child.guid = s.guid AND NOT child.isDeleted
1719             JOIN moz_bookmarks_synced parent
1720               ON parent.guid = s.parentGuid AND NOT parent.isDeleted
1721              AND parent.kind = {folder_kind}
1722             WHERE s.guid <> '{root_guid}'
1723             ORDER BY s.parentGuid, s.position",
1724            folder_kind = folder_kind,
1725            root_guid = root_guid_str.as_str()
1726        );
1727        let mut stmt = self.db.prepare(&sql)?;
1728        let mut results = stmt.query([])?;
1729        while let Some(row) = results.next()? {
1730            self.scope.err_if_interrupted()?;
1731            let guid = row.get::<_, SyncGuid>("guid")?;
1732            let parent_guid = row.get::<_, SyncGuid>("parentGuid")?;
1733            builder
1734                .parent_for(&guid.as_str().into())
1735                .by_children(&parent_guid.as_str().into())?;
1736        }
1737
1738        let tree = Tree::try_from(builder)?;
1739        Ok(tree)
1740    }
1741
1742    fn apply(&mut self, root: MergedRoot<'_>) -> Result<()> {
1743        let ops = root.completion_ops_with_signal(&MergeInterruptee(self.scope))?;
1744
1745        if ops.is_empty() {
1746            // If we don't have any items to apply, upload, or delete,
1747            // no need to open a transaction at all.
1748            return Ok(());
1749        }
1750
1751        let tx = if !self.external_transaction {
1752            Some(self.db.begin_transaction()?)
1753        } else {
1754            None
1755        };
1756
1757        // If the local tree has changed since we started the merge, we abort
1758        // in the expectation it will succeed next time.
1759        if self.global_change_tracker.changed() {
1760            info!("Aborting update of local items as local tree changed while merging");
1761            if let Some(tx) = tx {
1762                tx.rollback()?;
1763            }
1764            return Ok(());
1765        }
1766
1767        debug!("Updating local items in Places");
1768        update_local_items_in_places(self.db, self.scope, self.local_time, &ops)?;
1769
1770        debug!(
1771            "Staging {} items and {} tombstones to upload",
1772            ops.upload_items.len(),
1773            ops.upload_tombstones.len()
1774        );
1775        stage_items_to_upload(
1776            self.db,
1777            self.scope,
1778            &ops.upload_items,
1779            &ops.upload_tombstones,
1780        )?;
1781
1782        self.db.execute_batch("DELETE FROM itemsToApply;")?;
1783        if let Some(tx) = tx {
1784            tx.commit()?;
1785        }
1786        Ok(())
1787    }
1788}
1789
1790/// A helper that formats an optional value so that it can be included in a SQL
1791/// statement. `None` values become SQL `NULL`s.
1792struct NullableFragment<T>(Option<T>);
1793
1794impl<T> fmt::Display for NullableFragment<T>
1795where
1796    T: fmt::Display,
1797{
1798    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1799        match &self.0 {
1800            Some(v) => v.fmt(f),
1801            None => write!(f, "NULL"),
1802        }
1803    }
1804}
1805
1806/// A helper that interpolates a SQL `CASE` expression for converting a synced
1807/// item kind to a local item type. The expression evaluates to `NULL` if the
1808/// kind is unknown.
1809struct ItemTypeFragment(&'static str);
1810
1811impl fmt::Display for ItemTypeFragment {
1812    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1813        write!(
1814            f,
1815            "(CASE WHEN {col} IN ({bookmark_kind}, {query_kind})
1816                        THEN {bookmark_type}
1817                   WHEN {col} IN ({folder_kind}, {livemark_kind})
1818                        THEN {folder_type}
1819                   WHEN {col} = {separator_kind}
1820                        THEN {separator_type}
1821              END)",
1822            col = self.0,
1823            bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
1824            query_kind = SyncedBookmarkKind::Query as u8,
1825            bookmark_type = BookmarkType::Bookmark as u8,
1826            folder_kind = SyncedBookmarkKind::Folder as u8,
1827            livemark_kind = SyncedBookmarkKind::Livemark as u8,
1828            folder_type = BookmarkType::Folder as u8,
1829            separator_kind = SyncedBookmarkKind::Separator as u8,
1830            separator_type = BookmarkType::Separator as u8,
1831        )
1832    }
1833}
1834
1835/// Formats a `SELECT` statement for staging local items in the `itemsToUpload`
1836/// table.
1837struct UploadItemsFragment(&'static str);
1838
1839impl fmt::Display for UploadItemsFragment {
1840    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1841        write!(
1842            f,
1843            "SELECT {alias}.id, {alias}.guid, {alias}.syncChangeCounter,
1844                    p.guid AS parentGuid, p.title AS parentTitle,
1845                    {alias}.dateAdded, {kind_fragment} AS kind,
1846                    {alias}.title, h.id AS placeId, h.url,
1847                    (SELECT k.keyword FROM moz_keywords k
1848                     WHERE k.place_id = h.id) AS keyword,
1849                    {alias}.position
1850                FROM moz_bookmarks {alias}
1851                JOIN moz_bookmarks p ON p.id = {alias}.parent
1852                LEFT JOIN moz_places h ON h.id = {alias}.fk",
1853            alias = self.0,
1854            kind_fragment = item_kind_fragment(self.0, "type", UrlOrPlaceIdFragment::Url("h.url")),
1855        )
1856    }
1857}
1858
1859/// A helper that interpolates a named SQL common table expression (CTE) for
1860/// local items. The CTE may be included in a `WITH RECURSIVE` clause.
1861struct LocalItemsFragment<'a>(&'a str);
1862
1863impl fmt::Display for LocalItemsFragment<'_> {
1864    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1865        write!(
1866            f,
1867            "{name}(id, guid, parentId, parentGuid, position, type, title, parentTitle,
1868                    placeId, dateAdded, lastModified, syncChangeCounter, level) AS (
1869             SELECT b.id, b.guid, 0, NULL, b.position, b.type, b.title, NULL,
1870                    b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, 0
1871             FROM moz_bookmarks b
1872             WHERE b.guid = '{root_guid}'
1873             UNION ALL
1874             SELECT b.id, b.guid, s.id, s.guid, b.position, b.type, b.title, s.title,
1875                    b.fk, b.dateAdded, b.lastModified, b.syncChangeCounter, s.level + 1
1876             FROM moz_bookmarks b
1877             JOIN {name} s ON s.id = b.parent)",
1878            name = self.0,
1879            root_guid = BookmarkRootGuid::Root.as_guid().as_str()
1880        )
1881    }
1882}
1883
1884fn item_kind_fragment(
1885    table_name: &'static str,
1886    type_column_name: &'static str,
1887    url_or_place_id_fragment: UrlOrPlaceIdFragment,
1888) -> ItemKindFragment {
1889    ItemKindFragment {
1890        table_name,
1891        type_column_name,
1892        url_or_place_id_fragment,
1893    }
1894}
1895
1896/// A helper that interpolates a SQL `CASE` expression for converting a local
1897/// item type to a synced item kind. The expression evaluates to `NULL` if the
1898/// type is unknown.
1899struct ItemKindFragment {
1900    /// The name of the Places bookmarks table.
1901    table_name: &'static str,
1902    /// The name of the column containing the Places item type.
1903    type_column_name: &'static str,
1904    /// The column containing the item's URL or Place ID.
1905    url_or_place_id_fragment: UrlOrPlaceIdFragment,
1906}
1907
1908impl fmt::Display for ItemKindFragment {
1909    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1910        write!(
1911            f,
1912            "(CASE {table_name}.{type_column_name}
1913              WHEN {bookmark_type} THEN (
1914                  CASE substr({url}, 1, 6)
1915                  /* Queries are bookmarks with a 'place:' URL scheme. */
1916                  WHEN 'place:' THEN {query_kind}
1917                  ELSE {bookmark_kind}
1918                  END
1919              )
1920              WHEN {folder_type} THEN {folder_kind}
1921              WHEN {separator_type} THEN {separator_kind}
1922              END)",
1923            table_name = self.table_name,
1924            type_column_name = self.type_column_name,
1925            bookmark_type = BookmarkType::Bookmark as u8,
1926            url = self.url_or_place_id_fragment,
1927            query_kind = SyncedBookmarkKind::Query as u8,
1928            bookmark_kind = SyncedBookmarkKind::Bookmark as u8,
1929            folder_type = BookmarkType::Folder as u8,
1930            folder_kind = SyncedBookmarkKind::Folder as u8,
1931            separator_type = BookmarkType::Separator as u8,
1932            separator_kind = SyncedBookmarkKind::Separator as u8,
1933        )
1934    }
1935}
1936
1937/// A helper that interpolates a SQL expression for querying a local item's
1938/// URL. Note that the `&'static str` for each variant specifies the _name of
1939/// the column_ containing the URL or ID, not the URL or ID itself.
1940enum UrlOrPlaceIdFragment {
1941    /// The name of the column containing the URL. This avoids a subquery if
1942    /// a column for the URL already exists in the query.
1943    Url(&'static str),
1944    /// The name of the column containing the Place ID. This writes out a
1945    /// subquery to look up the URL.
1946    PlaceId(&'static str),
1947}
1948
1949impl fmt::Display for UrlOrPlaceIdFragment {
1950    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1951        match self {
1952            UrlOrPlaceIdFragment::Url(s) => write!(f, "{}", s),
1953            UrlOrPlaceIdFragment::PlaceId(s) => {
1954                write!(f, "(SELECT h.url FROM moz_places h WHERE h.id = {})", s)
1955            }
1956        }
1957    }
1958}
1959
1960/// A helper that interpolates a SQL list containing the given bookmark
1961/// root GUIDs.
1962struct RootsFragment<'a>(&'a [BookmarkRootGuid]);
1963
1964impl fmt::Display for RootsFragment<'_> {
1965    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1966        f.write_str("(")?;
1967        for (i, guid) in self.0.iter().enumerate() {
1968            if i != 0 {
1969                f.write_str(",")?;
1970            }
1971            write!(f, "'{}'", guid.as_str())?;
1972        }
1973        f.write_str(")")
1974    }
1975}
1976
1977#[cfg(test)]
1978mod tests {
1979    use super::*;
1980    use crate::api::places_api::{test::new_mem_api, ConnectionType, PlacesApi};
1981    use crate::bookmark_sync::tests::SyncedBookmarkItem;
1982    use crate::db::PlacesDb;
1983    use crate::storage::{
1984        bookmarks::{
1985            get_raw_bookmark, insert_bookmark, update_bookmark, BookmarkPosition,
1986            InsertableBookmark, UpdatableBookmark, USER_CONTENT_ROOTS,
1987        },
1988        history::frecency_stale_at,
1989        tags,
1990    };
1991    use crate::tests::{
1992        assert_json_tree as assert_local_json_tree, insert_json_tree as insert_local_json_tree,
1993    };
1994    use dogear::{Store as DogearStore, Validity};
1995    use rusqlite::{Error as RusqlError, ErrorCode};
1996    use serde_json::{json, Value};
1997    use std::{
1998        borrow::Cow,
1999        time::{Duration, SystemTime},
2000    };
2001    use sync15::bso::{IncomingBso, IncomingKind};
2002    use sync15::engine::CollSyncIds;
2003    use sync_guid::Guid;
2004    use url::Url;
2005
2006    // A helper type to simplify writing table-driven tests with synced items.
2007    struct ExpectedSyncedItem<'a>(SyncGuid, Cow<'a, SyncedBookmarkItem>);
2008
2009    impl<'a> ExpectedSyncedItem<'a> {
2010        fn new(
2011            guid: impl Into<SyncGuid>,
2012            expected: &'a SyncedBookmarkItem,
2013        ) -> ExpectedSyncedItem<'a> {
2014            ExpectedSyncedItem(guid.into(), Cow::Borrowed(expected))
2015        }
2016
2017        fn with_properties(
2018            guid: impl Into<SyncGuid>,
2019            expected: &'a SyncedBookmarkItem,
2020            f: impl FnOnce(&mut SyncedBookmarkItem) -> &mut SyncedBookmarkItem + 'static,
2021        ) -> ExpectedSyncedItem<'a> {
2022            let mut expected = expected.clone();
2023            f(&mut expected);
2024            ExpectedSyncedItem(guid.into(), Cow::Owned(expected))
2025        }
2026
2027        fn check(&self, conn: &PlacesDb) -> Result<()> {
2028            let actual =
2029                SyncedBookmarkItem::get(conn, &self.0)?.expect("Expected synced item should exist");
2030            assert_eq!(&actual, &*self.1);
2031            Ok(())
2032        }
2033    }
2034
2035    fn create_sync_engine(api: &PlacesApi) -> BookmarksSyncEngine {
2036        BookmarksSyncEngine::new(api.get_sync_connection().unwrap()).unwrap()
2037    }
2038
2039    fn engine_apply_incoming(
2040        engine: &BookmarksSyncEngine,
2041        incoming: Vec<IncomingBso>,
2042    ) -> Vec<OutgoingBso> {
2043        let mut telem = telemetry::Engine::new(engine.collection_name());
2044        engine
2045            .stage_incoming(incoming, &mut telem)
2046            .expect("Should stage incoming");
2047        engine
2048            .apply(ServerTimestamp(0), &mut telem)
2049            .expect("Should apply")
2050    }
2051
2052    // Applies the incoming records, and also "finishes" the sync by pretending
2053    // we uploaded the outgoing items and marks them as uploaded.
2054    // Returns the GUIDs of the outgoing items.
2055    fn apply_incoming(
2056        api: &PlacesApi,
2057        remote_time: ServerTimestamp,
2058        records_json: Value,
2059    ) -> Vec<Guid> {
2060        // suck records into the engine.
2061        let engine = create_sync_engine(api);
2062
2063        let incoming = match records_json {
2064            Value::Array(records) => records
2065                .into_iter()
2066                .map(|record| IncomingBso::from_test_content_ts(record, remote_time))
2067                .collect(),
2068            Value::Object(_) => {
2069                vec![IncomingBso::from_test_content_ts(records_json, remote_time)]
2070            }
2071            _ => panic!("unexpected json value"),
2072        };
2073
2074        engine_apply_incoming(&engine, incoming);
2075
2076        let sync_db = api.get_sync_connection().unwrap();
2077        let syncer = sync_db.lock();
2078        let mut stmt = syncer
2079            .prepare("SELECT guid FROM itemsToUpload")
2080            .expect("Should prepare statement to fetch uploaded GUIDs");
2081        let uploaded_guids: Vec<Guid> = stmt
2082            .query_and_then([], |row| -> rusqlite::Result<_> { row.get::<_, Guid>(0) })
2083            .expect("Should fetch uploaded GUIDs")
2084            .map(std::result::Result::unwrap)
2085            .collect();
2086
2087        push_synced_items(&syncer, &engine.scope, remote_time, uploaded_guids.clone())
2088            .expect("Should push synced changes back to the engine");
2089        uploaded_guids
2090    }
2091
2092    fn assert_incoming_creates_local_tree(
2093        api: &PlacesApi,
2094        records_json: Value,
2095        local_folder: &SyncGuid,
2096        local_tree: Value,
2097    ) {
2098        apply_incoming(api, ServerTimestamp(0), records_json);
2099        assert_local_json_tree(
2100            &api.get_sync_connection().unwrap().lock(),
2101            local_folder,
2102            local_tree,
2103        );
2104    }
2105
2106    #[test]
2107    fn test_fetch_remote_tree() -> Result<()> {
2108        let records = vec![
2109            json!({
2110                "id": "qqVTRWhLBOu3",
2111                "type": "bookmark",
2112                "parentid": "unfiled",
2113                "parentName": "Unfiled Bookmarks",
2114                "dateAdded": 1_381_542_355_843u64,
2115                "title": "The title",
2116                "bmkUri": "https://example.com",
2117                "tags": [],
2118            }),
2119            json!({
2120                "id": "unfiled",
2121                "type": "folder",
2122                "parentid": "places",
2123                "parentName": "",
2124                "dateAdded": 0,
2125                "title": "Unfiled Bookmarks",
2126                "children": ["qqVTRWhLBOu3"],
2127                "tags": [],
2128            }),
2129        ];
2130
2131        let api = new_mem_api();
2132        let db = api.get_sync_connection().unwrap();
2133        let conn = db.lock();
2134
2135        // suck records into the database.
2136        let interrupt_scope = conn.begin_interrupt_scope()?;
2137
2138        let incoming = records
2139            .into_iter()
2140            .map(IncomingBso::from_test_content)
2141            .collect();
2142
2143        stage_incoming(
2144            &conn,
2145            &interrupt_scope,
2146            incoming,
2147            &mut telemetry::EngineIncoming::new(),
2148        )
2149        .expect("Should apply incoming and stage outgoing records");
2150
2151        let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2152
2153        let tree = merger.fetch_remote_tree()?;
2154
2155        // should be each user root, plus the real root, plus the bookmark we added.
2156        assert_eq!(tree.guids().count(), USER_CONTENT_ROOTS.len() + 2);
2157
2158        let node = tree
2159            .node_for_guid(&"qqVTRWhLBOu3".into())
2160            .expect("should exist");
2161        assert!(node.needs_merge);
2162        assert_eq!(node.validity, Validity::Valid);
2163        assert_eq!(node.level(), 2);
2164        assert!(node.is_syncable());
2165
2166        let node = tree
2167            .node_for_guid(&BookmarkRootGuid::Unfiled.as_guid().as_str().into())
2168            .expect("should exist");
2169        assert!(node.needs_merge);
2170        assert_eq!(node.validity, Validity::Valid);
2171        assert_eq!(node.level(), 1);
2172        assert!(node.is_syncable());
2173
2174        let node = tree
2175            .node_for_guid(&BookmarkRootGuid::Menu.as_guid().as_str().into())
2176            .expect("should exist");
2177        assert!(!node.needs_merge);
2178        assert_eq!(node.validity, Validity::Valid);
2179        assert_eq!(node.level(), 1);
2180        assert!(node.is_syncable());
2181
2182        let node = tree
2183            .node_for_guid(&BookmarkRootGuid::Root.as_guid().as_str().into())
2184            .expect("should exist");
2185        assert_eq!(node.validity, Validity::Valid);
2186        assert_eq!(node.level(), 0);
2187        assert!(!node.is_syncable());
2188
2189        // We should have changes.
2190        assert!(db_has_changes(&conn).unwrap());
2191        Ok(())
2192    }
2193
2194    // Bug 2039791.
2195    // Verifies that fetch_remote_tree fails with MissingParentForUnknownChild when
2196    // moz_bookmarks_synced_structure has a row whose child and parent are both
2197    // tombstoned (isDeleted=1, needsMerge=0). The FK on parentGuid is
2198    // satisfied because tombstoned rows still exist in moz_bookmarks_synced.
2199    #[test]
2200    fn test_fetch_remote_tree_with_tombstoned_structure_endpoints() -> Result<()> {
2201        let api = new_mem_api();
2202        let db = api.get_sync_connection().unwrap();
2203        let conn = db.lock();
2204        let interrupt_scope = conn.begin_interrupt_scope()?;
2205
2206        conn.execute_batch(&format!(
2207            "INSERT INTO moz_bookmarks_synced(guid, isDeleted, needsMerge, kind)
2208             VALUES ('Pppppppppppp', 1, 0, {folder}),
2209                    ('Cccccccccccc', 1, 0, {bookmark});
2210             INSERT INTO moz_bookmarks_synced_structure(guid, parentGuid, position)
2211             VALUES ('Cccccccccccc', 'Pppppppppppp', 0);",
2212            folder = SyncedBookmarkKind::Folder as u8,
2213            bookmark = SyncedBookmarkKind::Bookmark as u8,
2214        ))?;
2215
2216        let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2217        // With bug 2039791, fetch_remote_tree filters out orphaned structure rows and
2218        // succeeds. Without that patch, this returned MissingParentForUnknownChild.
2219        let tree = merger.fetch_remote_tree()?;
2220        // The orphaned guids are skipped and do not appear in the tree.
2221        assert!(tree.node_for_guid(&"Cccccccccccc".into()).is_none());
2222        assert!(tree.node_for_guid(&"Pppppppppppp".into()).is_none());
2223        Ok(())
2224    }
2225
2226    // Bug 2039791.
2227    // Verifies that fetch_remote_tree fails with InvalidParent when
2228    // moz_bookmarks_synced_structure has a row whose parentGuid refers to a
2229    // non-folder (eg, kind=Bookmark). Both endpoints are inserted into the dogear
2230    // builder, but dogear's by_children rejects a non-folder parent.
2231    #[test]
2232    fn test_fetch_remote_tree_with_non_folder_parent_in_structure() -> Result<()> {
2233        let api = new_mem_api();
2234        let db = api.get_sync_connection().unwrap();
2235        let conn = db.lock();
2236        let interrupt_scope = conn.begin_interrupt_scope()?;
2237
2238        conn.execute_batch(&format!(
2239            "INSERT INTO moz_bookmarks_synced(guid, isDeleted, needsMerge, kind, validity)
2240             VALUES ('Pppppppppppp', 0, 0, {bookmark}, {valid}),
2241                    ('Cccccccccccc', 0, 0, {bookmark}, {valid});
2242             INSERT INTO moz_bookmarks_synced_structure(guid, parentGuid, position)
2243             VALUES ('Cccccccccccc', 'Pppppppppppp', 0);",
2244            bookmark = SyncedBookmarkKind::Bookmark as u8,
2245            valid = SyncedBookmarkValidity::Valid as u8,
2246        ))?;
2247
2248        let merger = Merger::new(&conn, &interrupt_scope, ServerTimestamp(0));
2249        // With bug 2039791, fetch_remote_tree filters out structure rows whose parent
2250        // is not a folder and succeeds. Without that patch this returned InvalidParent.
2251        let tree = merger.fetch_remote_tree()?;
2252        // Both items are still in the tree (inserted as live items by pass 2), but
2253        // without the bad structural relationship: Cccccccccccc is not a child of
2254        // Pppppppppppp. Both items are orphans reparented to unfiled.
2255        let c_node = tree
2256            .node_for_guid(&"Cccccccccccc".into())
2257            .expect("Cccccccccccc should be in tree as orphan");
2258        let c_parent_guid = c_node.parent().map(|p| p.item().guid.clone());
2259        assert_ne!(
2260            c_parent_guid.as_deref(),
2261            Some("Pppppppppppp"),
2262            "Cccccccccccc should not be parented to the non-folder Pppppppppppp"
2263        );
2264        Ok(())
2265    }
2266
2267    #[test]
2268    fn test_fetch_local_tree() -> Result<()> {
2269        let now = SystemTime::now();
2270        let previously_ts: Timestamp = (now - Duration::new(10, 0)).into();
2271        let api = new_mem_api();
2272        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2273        let sync_db = api.get_sync_connection().unwrap();
2274        let syncer = sync_db.lock();
2275
2276        writer
2277            .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
2278            .expect("should work");
2279
2280        insert_local_json_tree(
2281            &writer,
2282            json!({
2283                "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2284                "children": [
2285                    {
2286                        "guid": "bookmark1___",
2287                        "title": "the bookmark",
2288                        "url": "https://www.example.com/",
2289                        "last_modified": previously_ts,
2290                        "date_added": previously_ts,
2291                    },
2292                ]
2293            }),
2294        );
2295
2296        let interrupt_scope = syncer.begin_interrupt_scope()?;
2297        let merger =
2298            Merger::with_localtime(&syncer, &interrupt_scope, ServerTimestamp(0), now.into());
2299
2300        let tree = merger.fetch_local_tree()?;
2301
2302        // should be each user root, plus the real root, plus the bookmark we added.
2303        assert_eq!(tree.guids().count(), USER_CONTENT_ROOTS.len() + 2);
2304
2305        let node = tree
2306            .node_for_guid(&"bookmark1___".into())
2307            .expect("should exist");
2308        assert!(node.needs_merge);
2309        assert_eq!(node.level(), 2);
2310        assert!(node.is_syncable());
2311        assert_eq!(node.age, 10000);
2312
2313        let node = tree
2314            .node_for_guid(&BookmarkRootGuid::Unfiled.as_guid().as_str().into())
2315            .expect("should exist");
2316        assert!(node.needs_merge);
2317        assert_eq!(node.level(), 1);
2318        assert!(node.is_syncable());
2319
2320        let node = tree
2321            .node_for_guid(&BookmarkRootGuid::Menu.as_guid().as_str().into())
2322            .expect("should exist");
2323        assert!(!node.needs_merge);
2324        assert_eq!(node.level(), 1);
2325        assert!(node.is_syncable());
2326
2327        let node = tree
2328            .node_for_guid(&BookmarkRootGuid::Root.as_guid().as_str().into())
2329            .expect("should exist");
2330        assert!(!node.needs_merge);
2331        assert_eq!(node.level(), 0);
2332        assert!(!node.is_syncable());
2333        // hard to know the exact age of the root, but we know the max.
2334        let max_dur = SystemTime::now().duration_since(now).unwrap();
2335        let max_age = max_dur.as_secs() as i64 * 1000 + i64::from(max_dur.subsec_millis());
2336        assert!(node.age <= max_age);
2337
2338        // We should have changes.
2339        assert!(db_has_changes(&syncer).unwrap());
2340        Ok(())
2341    }
2342
2343    #[test]
2344    fn test_apply_bookmark() {
2345        let api = new_mem_api();
2346        assert_incoming_creates_local_tree(
2347            &api,
2348            json!([{
2349                "id": "bookmark1___",
2350                "type": "bookmark",
2351                "parentid": "unfiled",
2352                "parentName": "Unfiled Bookmarks",
2353                "dateAdded": 1_381_542_355_843u64,
2354                "title": "Some bookmark",
2355                "bmkUri": "http://example.com",
2356            },
2357            {
2358                "id": "unfiled",
2359                "type": "folder",
2360                "parentid": "places",
2361                "dateAdded": 1_381_542_355_843u64,
2362                "title": "Unfiled",
2363                "children": ["bookmark1___"],
2364            }]),
2365            &BookmarkRootGuid::Unfiled.as_guid(),
2366            json!({"children" : [{"guid": "bookmark1___", "url": "http://example.com"}]}),
2367        );
2368        let reader = api
2369            .open_connection(ConnectionType::ReadOnly)
2370            .expect("Should open read-only connection");
2371        assert!(
2372            frecency_stale_at(&reader, &Url::parse("http://example.com").unwrap())
2373                .expect("Should check stale frecency")
2374                .is_some(),
2375            "Should mark frecency for bookmark URL as stale"
2376        );
2377
2378        let writer = api
2379            .open_connection(ConnectionType::ReadWrite)
2380            .expect("Should open read-write connection");
2381        insert_local_json_tree(
2382            &writer,
2383            json!({
2384                "guid": &BookmarkRootGuid::Menu.as_guid(),
2385                "children": [
2386                    {
2387                        "guid": "bookmark2___",
2388                        "title": "2",
2389                        "url": "http://example.com/2",
2390                    }
2391                ],
2392            }),
2393        );
2394        assert_incoming_creates_local_tree(
2395            &api,
2396            json!([{
2397                "id": "menu",
2398                "type": "folder",
2399                "parentid": "places",
2400                "parentName": "",
2401                "dateAdded": 0,
2402                "title": "menu",
2403                "children": ["bookmark2___"],
2404            }, {
2405                "id": "bookmark2___",
2406                "type": "bookmark",
2407                "parentid": "menu",
2408                "parentName": "menu",
2409                "dateAdded": 1_381_542_355_843u64,
2410                "title": "2",
2411                "bmkUri": "http://example.com/2-remote",
2412            }]),
2413            &BookmarkRootGuid::Menu.as_guid(),
2414            json!({"children" : [{"guid": "bookmark2___", "url": "http://example.com/2-remote"}]}),
2415        );
2416        assert!(
2417            frecency_stale_at(&reader, &Url::parse("http://example.com/2").unwrap())
2418                .expect("Should check stale frecency for old URL")
2419                .is_some(),
2420            "Should mark frecency for old URL as stale"
2421        );
2422        assert!(
2423            frecency_stale_at(&reader, &Url::parse("http://example.com/2-remote").unwrap())
2424                .expect("Should check stale frecency for new URL")
2425                .is_some(),
2426            "Should mark frecency for new URL as stale"
2427        );
2428
2429        let sync_db = api.get_sync_connection().unwrap();
2430        let syncer = sync_db.lock();
2431        let interrupt_scope = syncer.begin_interrupt_scope().unwrap();
2432
2433        update_frecencies(&syncer, &interrupt_scope).expect("Should update frecencies");
2434
2435        assert!(
2436            frecency_stale_at(&reader, &Url::parse("http://example.com").unwrap())
2437                .expect("Should check stale frecency")
2438                .is_none(),
2439            "Should recalculate frecency for first bookmark"
2440        );
2441        assert!(
2442            frecency_stale_at(&reader, &Url::parse("http://example.com/2").unwrap())
2443                .expect("Should check stale frecency for old URL")
2444                .is_none(),
2445            "Should recalculate frecency for old URL"
2446        );
2447        assert!(
2448            frecency_stale_at(&reader, &Url::parse("http://example.com/2-remote").unwrap())
2449                .expect("Should check stale frecency for new URL")
2450                .is_none(),
2451            "Should recalculate frecency for new URL"
2452        );
2453    }
2454
2455    #[test]
2456    fn test_apply_complex_bookmark_tags() -> Result<()> {
2457        let api = new_mem_api();
2458        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2459
2460        // Insert two local bookmarks with the same URL A (so they'll have
2461        // identical tags) and a third with a different URL B, but one same
2462        // tag as A.
2463        let local_bookmarks = vec![
2464            InsertableBookmark {
2465                parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2466                position: BookmarkPosition::Append,
2467                date_added: None,
2468                last_modified: None,
2469                guid: Some("bookmarkAAA1".into()),
2470                url: Url::parse("http://example.com/a").unwrap(),
2471                title: Some("A1".into()),
2472            }
2473            .into(),
2474            InsertableBookmark {
2475                parent_guid: BookmarkRootGuid::Menu.as_guid(),
2476                position: BookmarkPosition::Append,
2477                date_added: None,
2478                last_modified: None,
2479                guid: Some("bookmarkAAA2".into()),
2480                url: Url::parse("http://example.com/a").unwrap(),
2481                title: Some("A2".into()),
2482            }
2483            .into(),
2484            InsertableBookmark {
2485                parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2486                position: BookmarkPosition::Append,
2487                date_added: None,
2488                last_modified: None,
2489                guid: Some("bookmarkBBBB".into()),
2490                url: Url::parse("http://example.com/b").unwrap(),
2491                title: Some("B".into()),
2492            }
2493            .into(),
2494        ];
2495        let local_tags = &[
2496            ("http://example.com/a", vec!["one", "two"]),
2497            (
2498                "http://example.com/b",
2499                // Local duplicate tags should be ignored.
2500                vec!["two", "three", "three", "four"],
2501            ),
2502        ];
2503        for bm in local_bookmarks.into_iter() {
2504            insert_bookmark(&writer, bm)?;
2505        }
2506        for (url, tags) in local_tags {
2507            let url = Url::parse(url)?;
2508            for t in tags.iter() {
2509                tags::tag_url(&writer, &url, t)?;
2510            }
2511        }
2512
2513        // Now for some fun server data. Only B, C, and F2 have problems;
2514        // D and E are fine, and shouldn't be reuploaded.
2515        let remote_records = json!([{
2516            // Change B's tags on the server, and duplicate `two` for good
2517            // measure. We should reupload B with only one `two` tag.
2518            "id": "bookmarkBBBB",
2519            "type": "bookmark",
2520            "parentid": "unfiled",
2521            "parentName": "Unfiled",
2522            "dateAdded": 1_381_542_355_843u64,
2523            "title": "B",
2524            "bmkUri": "http://example.com/b",
2525            "tags": ["two", "two", "three", "eight"],
2526        }, {
2527            // C is an example of bad data on the server: bookmarks with the
2528            // same URL should have the same tags, but C1/C2 have different tags
2529            // than C3. We should reupload all of them.
2530            "id": "bookmarkCCC1",
2531            "type": "bookmark",
2532            "parentid": "unfiled",
2533            "parentName": "Unfiled",
2534            "dateAdded": 1_381_542_355_843u64,
2535            "title": "C1",
2536            "bmkUri": "http://example.com/c",
2537            "tags": ["four", "five", "six"],
2538        }, {
2539            "id": "bookmarkCCC2",
2540            "type": "bookmark",
2541            "parentid": "menu",
2542            "parentName": "Menu",
2543            "dateAdded": 1_381_542_355_843u64,
2544            "title": "C2",
2545            "bmkUri": "http://example.com/c",
2546            "tags": ["four", "five", "six"],
2547        }, {
2548            "id": "bookmarkCCC3",
2549            "type": "bookmark",
2550            "parentid": "menu",
2551            "parentName": "Menu",
2552            "dateAdded": 1_381_542_355_843u64,
2553            "title": "C3",
2554            "bmkUri": "http://example.com/c",
2555            "tags": ["six", "six", "seven"],
2556        }, {
2557            // D has the same tags as C1/2, but a different URL. This is
2558            // perfectly fine, since URLs and tags are many-many! D also
2559            // isn't duplicated, so it'll be filtered out by the
2560            // `HAVING COUNT(*) > 1` clause.
2561            "id": "bookmarkDDDD",
2562            "type": "bookmark",
2563            "parentid": "unfiled",
2564            "parentName": "Unfiled",
2565            "dateAdded": 1_381_542_355_843u64,
2566            "title": "D",
2567            "bmkUri": "http://example.com/d",
2568            "tags": ["four", "five", "six"],
2569        }, {
2570            // E1 and E2 have the same URLs and the same tags, so we shouldn't
2571            // reupload either.
2572            "id": "bookmarkEEE1",
2573            "type": "bookmark",
2574            "parentid": "toolbar",
2575            "parentName": "Toolbar",
2576            "dateAdded": 1_381_542_355_843u64,
2577            "title": "E1",
2578            "bmkUri": "http://example.com/e",
2579            "tags": ["nine", "ten", "eleven"],
2580        }, {
2581            "id": "bookmarkEEE2",
2582            "type": "bookmark",
2583            "parentid": "mobile",
2584            "parentName": "Mobile",
2585            "dateAdded": 1_381_542_355_843u64,
2586            "title": "E2",
2587            "bmkUri": "http://example.com/e",
2588            "tags": ["nine", "ten", "eleven"],
2589        }, {
2590            // F1 and F2 have mismatched tags, but with a twist: F2 doesn't
2591            // have _any_ tags! We should only reupload F2.
2592            "id": "bookmarkFFF1",
2593            "type": "bookmark",
2594            "parentid": "toolbar",
2595            "parentName": "Toolbar",
2596            "dateAdded": 1_381_542_355_843u64,
2597            "title": "F1",
2598            "bmkUri": "http://example.com/f",
2599            "tags": ["twelve"],
2600        }, {
2601            "id": "bookmarkFFF2",
2602            "type": "bookmark",
2603            "parentid": "mobile",
2604            "parentName": "Mobile",
2605            "dateAdded": 1_381_542_355_843u64,
2606            "title": "F2",
2607            "bmkUri": "http://example.com/f",
2608        }, {
2609            "id": "unfiled",
2610            "type": "folder",
2611            "parentid": "places",
2612            "dateAdded": 1_381_542_355_843u64,
2613            "title": "Unfiled",
2614            "children": ["bookmarkBBBB", "bookmarkCCC1", "bookmarkDDDD"],
2615        }, {
2616            "id": "menu",
2617            "type": "folder",
2618            "parentid": "places",
2619            "dateAdded": 1_381_542_355_843u64,
2620            "title": "Menu",
2621            "children": ["bookmarkCCC2", "bookmarkCCC3"],
2622        }, {
2623            "id": "toolbar",
2624            "type": "folder",
2625            "parentid": "places",
2626            "dateAdded": 1_381_542_355_843u64,
2627            "title": "Toolbar",
2628            "children": ["bookmarkEEE1", "bookmarkFFF1"],
2629        }, {
2630            "id": "mobile",
2631            "type": "folder",
2632            "parentid": "places",
2633            "dateAdded": 1_381_542_355_843u64,
2634            "title": "Mobile",
2635            "children": ["bookmarkEEE2", "bookmarkFFF2"],
2636        }]);
2637
2638        // Boilerplate to apply incoming records, since we want to check
2639        // outgoing record contents.
2640        let engine = create_sync_engine(&api);
2641        let incoming = if let Value::Array(records) = remote_records {
2642            records
2643                .into_iter()
2644                .map(IncomingBso::from_test_content)
2645                .collect()
2646        } else {
2647            unreachable!("JSON records must be an array");
2648        };
2649        let mut outgoing = engine_apply_incoming(&engine, incoming);
2650        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
2651
2652        // Verify that we applied all incoming records correctly.
2653        assert_local_json_tree(
2654            &writer,
2655            &BookmarkRootGuid::Root.as_guid(),
2656            json!({
2657                "guid": &BookmarkRootGuid::Root.as_guid(),
2658                "children": [{
2659                    "guid": &BookmarkRootGuid::Menu.as_guid(),
2660                    "children": [{
2661                        "guid": "bookmarkCCC2",
2662                        "title": "C2",
2663                        "url": "http://example.com/c",
2664                    }, {
2665                        "guid": "bookmarkCCC3",
2666                        "title": "C3",
2667                        "url": "http://example.com/c",
2668                    }, {
2669                        "guid": "bookmarkAAA2",
2670                        "title": "A2",
2671                        "url": "http://example.com/a",
2672                    }],
2673                }, {
2674                    "guid": &BookmarkRootGuid::Toolbar.as_guid(),
2675                    "children": [{
2676                        "guid": "bookmarkEEE1",
2677                        "title": "E1",
2678                        "url": "http://example.com/e",
2679                    }, {
2680                        "guid": "bookmarkFFF1",
2681                        "title": "F1",
2682                        "url": "http://example.com/f",
2683                    }],
2684                }, {
2685                    "guid": &BookmarkRootGuid::Unfiled.as_guid(),
2686                    "children": [{
2687                        "guid": "bookmarkBBBB",
2688                        "title": "B",
2689                        "url": "http://example.com/b",
2690                    }, {
2691                        "guid": "bookmarkCCC1",
2692                        "title": "C1",
2693                        "url": "http://example.com/c",
2694                    }, {
2695                        "guid": "bookmarkDDDD",
2696                        "title": "D",
2697                        "url": "http://example.com/d",
2698                    }, {
2699                        "guid": "bookmarkAAA1",
2700                        "title": "A1",
2701                        "url": "http://example.com/a",
2702                    }],
2703                }, {
2704                    "guid": &BookmarkRootGuid::Mobile.as_guid(),
2705                    "children": [{
2706                        "guid": "bookmarkEEE2",
2707                        "title": "E2",
2708                        "url": "http://example.com/e",
2709                    }, {
2710                        "guid": "bookmarkFFF2",
2711                        "title": "F2",
2712                        "url": "http://example.com/f",
2713                    }],
2714                }],
2715            }),
2716        );
2717        // And verify our local tags are correct, too.
2718        let expected_local_tags = &[
2719            ("http://example.com/a", vec!["one", "two"]),
2720            ("http://example.com/b", vec!["eight", "three", "two"]),
2721            ("http://example.com/c", vec!["five", "four", "seven", "six"]),
2722            ("http://example.com/d", vec!["five", "four", "six"]),
2723            ("http://example.com/e", vec!["eleven", "nine", "ten"]),
2724            ("http://example.com/f", vec!["twelve"]),
2725        ];
2726        for (href, expected) in expected_local_tags {
2727            let mut actual = tags::get_tags_for_url(&writer, &Url::parse(href).unwrap())?;
2728            actual.sort();
2729            assert_eq!(&actual, expected);
2730        }
2731
2732        let expected_outgoing_ids = &[
2733            "bookmarkAAA1", // A is new locally.
2734            "bookmarkAAA2",
2735            "bookmarkBBBB", // B has a duplicate tag.
2736            "bookmarkCCC1", // C has mismatched tags.
2737            "bookmarkCCC2",
2738            "bookmarkCCC3",
2739            "bookmarkFFF2", // F2 is missing tags.
2740            "menu",         // Roots always get uploaded on the first sync.
2741            "mobile",
2742            "toolbar",
2743            "unfiled",
2744        ];
2745        assert_eq!(
2746            outgoing
2747                .iter()
2748                .map(|p| p.envelope.id.as_str())
2749                .collect::<Vec<_>>(),
2750            expected_outgoing_ids,
2751            "Should upload new bookmarks and fix up tags",
2752        );
2753
2754        // Now push the records back to the engine, so we can check what we're
2755        // uploading.
2756        engine
2757            .set_uploaded(
2758                ServerTimestamp(0),
2759                expected_outgoing_ids.iter().map(SyncGuid::from).collect(),
2760            )
2761            .expect("Should push synced changes back to the engine");
2762        engine.sync_finished().expect("should work");
2763
2764        // A and C should have the same URL and tags, and should be valid now.
2765        // Because the builder methods take a `&mut SyncedBookmarkItem`, and we
2766        // want to hang on to our base items for cloning later, we can't use
2767        // one-liners to create them.
2768        let mut synced_item_for_a = SyncedBookmarkItem::new();
2769        synced_item_for_a
2770            .validity(SyncedBookmarkValidity::Valid)
2771            .kind(SyncedBookmarkKind::Bookmark)
2772            .url(Some("http://example.com/a"))
2773            .tags(["one", "two"].iter().map(|&tag| tag.into()).collect());
2774        let mut synced_item_for_b = SyncedBookmarkItem::new();
2775        synced_item_for_b
2776            .validity(SyncedBookmarkValidity::Valid)
2777            .kind(SyncedBookmarkKind::Bookmark)
2778            .url(Some("http://example.com/b"))
2779            .tags(
2780                ["eight", "three", "two"]
2781                    .iter()
2782                    .map(|&tag| tag.into())
2783                    .collect(),
2784            )
2785            .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2786            .title(Some("B"));
2787        let mut synced_item_for_c = SyncedBookmarkItem::new();
2788        synced_item_for_c
2789            .validity(SyncedBookmarkValidity::Valid)
2790            .kind(SyncedBookmarkKind::Bookmark)
2791            .url(Some("http://example.com/c"))
2792            .tags(
2793                ["five", "four", "seven", "six"]
2794                    .iter()
2795                    .map(|&tag| tag.into())
2796                    .collect(),
2797            );
2798        let mut synced_item_for_f = SyncedBookmarkItem::new();
2799        synced_item_for_f
2800            .validity(SyncedBookmarkValidity::Valid)
2801            .kind(SyncedBookmarkKind::Bookmark)
2802            .url(Some("http://example.com/f"))
2803            .tags(vec!["twelve".into()]);
2804        // A table-driven test to clean up some of the boilerplate. We clone
2805        // the base item for each test, and pass it to the boxed closure to set
2806        // additional properties.
2807        let expected_synced_items = &[
2808            ExpectedSyncedItem::with_properties("bookmarkAAA1", &synced_item_for_a, |a| {
2809                a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2810                    .title(Some("A1"))
2811            }),
2812            ExpectedSyncedItem::with_properties("bookmarkAAA2", &synced_item_for_a, |a| {
2813                a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2814                    .title(Some("A2"))
2815            }),
2816            ExpectedSyncedItem::new("bookmarkBBBB", &synced_item_for_b),
2817            ExpectedSyncedItem::with_properties("bookmarkCCC1", &synced_item_for_c, |c| {
2818                c.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2819                    .title(Some("C1"))
2820            }),
2821            ExpectedSyncedItem::with_properties("bookmarkCCC2", &synced_item_for_c, |c| {
2822                c.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2823                    .title(Some("C2"))
2824            }),
2825            ExpectedSyncedItem::with_properties("bookmarkCCC3", &synced_item_for_c, |c| {
2826                c.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
2827                    .title(Some("C3"))
2828            }),
2829            ExpectedSyncedItem::with_properties(
2830                // We didn't reupload F1, but let's make sure it's still valid.
2831                "bookmarkFFF1",
2832                &synced_item_for_f,
2833                |f| {
2834                    f.parent_guid(Some(&BookmarkRootGuid::Toolbar.as_guid()))
2835                        .title(Some("F1"))
2836                },
2837            ),
2838            ExpectedSyncedItem::with_properties("bookmarkFFF2", &synced_item_for_f, |f| {
2839                f.parent_guid(Some(&BookmarkRootGuid::Mobile.as_guid()))
2840                    .title(Some("F2"))
2841            }),
2842        ];
2843        for item in expected_synced_items {
2844            item.check(&writer)?;
2845        }
2846
2847        Ok(())
2848    }
2849
2850    #[test]
2851    fn test_apply_bookmark_tags() -> Result<()> {
2852        let api = new_mem_api();
2853        let writer = api.open_connection(ConnectionType::ReadWrite)?;
2854
2855        // Insert local item with tagged URL.
2856        insert_bookmark(
2857            &writer,
2858            InsertableBookmark {
2859                parent_guid: BookmarkRootGuid::Unfiled.as_guid(),
2860                position: BookmarkPosition::Append,
2861                date_added: None,
2862                last_modified: None,
2863                guid: Some("bookmarkAAAA".into()),
2864                url: Url::parse("http://example.com/a").unwrap(),
2865                title: Some("A".into()),
2866            }
2867            .into(),
2868        )?;
2869        tags::tag_url(&writer, &Url::parse("http://example.com/a").unwrap(), "one")?;
2870
2871        let mut tags_for_a =
2872            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/a").unwrap())?;
2873        tags_for_a.sort();
2874        assert_eq!(tags_for_a, vec!["one".to_owned()]);
2875
2876        assert_incoming_creates_local_tree(
2877            &api,
2878            json!([{
2879                "id": "bookmarkBBBB",
2880                "type": "bookmark",
2881                "parentid": "unfiled",
2882                "parentName": "Unfiled",
2883                "dateAdded": 1_381_542_355_843u64,
2884                "title": "B",
2885                "bmkUri": "http://example.com/b",
2886                "tags": ["one", "two"],
2887            }, {
2888                "id": "bookmarkCCCC",
2889                "type": "bookmark",
2890                "parentid": "unfiled",
2891                "parentName": "Unfiled",
2892                "dateAdded": 1_381_542_355_843u64,
2893                "title": "C",
2894                "bmkUri": "http://example.com/c",
2895                "tags": ["three"],
2896            }, {
2897                "id": "unfiled",
2898                "type": "folder",
2899                "parentid": "places",
2900                "dateAdded": 1_381_542_355_843u64,
2901                "title": "Unfiled",
2902                "children": ["bookmarkBBBB", "bookmarkCCCC"],
2903            }]),
2904            &BookmarkRootGuid::Unfiled.as_guid(),
2905            json!({"children" : [
2906                  {"guid": "bookmarkBBBB", "url": "http://example.com/b"},
2907                  {"guid": "bookmarkCCCC", "url": "http://example.com/c"},
2908                  {"guid": "bookmarkAAAA", "url": "http://example.com/a"},
2909            ]}),
2910        );
2911
2912        let mut tags_for_a =
2913            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/a").unwrap())?;
2914        tags_for_a.sort();
2915        assert_eq!(tags_for_a, vec!["one".to_owned()]);
2916
2917        let mut tags_for_b =
2918            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/b").unwrap())?;
2919        tags_for_b.sort();
2920        assert_eq!(tags_for_b, vec!["one".to_owned(), "two".to_owned()]);
2921
2922        let mut tags_for_c =
2923            tags::get_tags_for_url(&writer, &Url::parse("http://example.com/c").unwrap())?;
2924        tags_for_c.sort();
2925        assert_eq!(tags_for_c, vec!["three".to_owned()]);
2926
2927        let synced_item_for_a = SyncedBookmarkItem::get(&writer, &"bookmarkAAAA".into())
2928            .expect("Should fetch A")
2929            .expect("A should exist");
2930        assert_eq!(
2931            synced_item_for_a,
2932            *SyncedBookmarkItem::new()
2933                .validity(SyncedBookmarkValidity::Valid)
2934                .kind(SyncedBookmarkKind::Bookmark)
2935                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2936                .title(Some("A"))
2937                .url(Some("http://example.com/a"))
2938                .tags(vec!["one".into()])
2939        );
2940
2941        let synced_item_for_b = SyncedBookmarkItem::get(&writer, &"bookmarkBBBB".into())
2942            .expect("Should fetch B")
2943            .expect("B should exist");
2944        assert_eq!(
2945            synced_item_for_b,
2946            *SyncedBookmarkItem::new()
2947                .validity(SyncedBookmarkValidity::Valid)
2948                .kind(SyncedBookmarkKind::Bookmark)
2949                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
2950                .title(Some("B"))
2951                .url(Some("http://example.com/b"))
2952                .tags(vec!["one".into(), "two".into()])
2953        );
2954
2955        Ok(())
2956    }
2957
2958    #[test]
2959    fn test_apply_bookmark_keyword() -> Result<()> {
2960        let api = new_mem_api();
2961
2962        let records = json!([{
2963            "id": "bookmarkAAAA",
2964            "type": "bookmark",
2965            "parentid": "unfiled",
2966            "parentName": "Unfiled",
2967            "dateAdded": 1_381_542_355_843u64,
2968            "title": "A",
2969            "bmkUri": "http://example.com/a?b=c&d=%s",
2970            "keyword": "ex",
2971        },
2972        {
2973            "id": "unfiled",
2974            "type": "folder",
2975            "parentid": "places",
2976            "dateAdded": 1_381_542_355_843u64,
2977            "title": "Unfiled",
2978            "children": ["bookmarkAAAA"],
2979        }]);
2980
2981        let db_mutex = api.get_sync_connection().unwrap();
2982        let db = db_mutex.lock();
2983        let tx = db.begin_transaction()?;
2984        let applicator = IncomingApplicator::new(&db);
2985
2986        if let Value::Array(records) = records {
2987            for record in records {
2988                applicator.apply_bso(IncomingBso::from_test_content(record))?;
2989            }
2990        } else {
2991            unreachable!("JSON records must be an array");
2992        }
2993
2994        tx.commit()?;
2995
2996        // Flag the bookmark with the keyword for reupload, so that we can
2997        // ensure the keyword is round-tripped correctly.
2998        db.execute(
2999            "UPDATE moz_bookmarks_synced SET
3000                 validity = :validity
3001             WHERE guid = :guid",
3002            rusqlite::named_params! {
3003                ":validity": SyncedBookmarkValidity::Reupload,
3004                ":guid": SyncGuid::from("bookmarkAAAA"),
3005            },
3006        )?;
3007
3008        let interrupt_scope = db.begin_interrupt_scope()?;
3009
3010        let mut merger = Merger::new(&db, &interrupt_scope, ServerTimestamp(0));
3011        merger.merge()?;
3012
3013        assert_local_json_tree(
3014            &db,
3015            &BookmarkRootGuid::Unfiled.as_guid(),
3016            json!({"children" : [{"guid": "bookmarkAAAA", "url": "http://example.com/a?b=c&d=%s"}]}),
3017        );
3018
3019        let outgoing = fetch_outgoing_records(&db, &interrupt_scope)?;
3020        let record_for_a = outgoing
3021            .iter()
3022            .find(|payload| payload.envelope.id == "bookmarkAAAA")
3023            .expect("Should reupload A");
3024        let bk = record_for_a.to_test_incoming_t::<BookmarkRecord>();
3025        assert_eq!(bk.url.unwrap(), "http://example.com/a?b=c&d=%s");
3026        assert_eq!(bk.keyword.unwrap(), "ex");
3027
3028        Ok(())
3029    }
3030
3031    #[test]
3032    fn test_apply_query() {
3033        // should we add some more query variations here?
3034        let api = new_mem_api();
3035        assert_incoming_creates_local_tree(
3036            &api,
3037            json!([{
3038                "id": "query1______",
3039                "type": "query",
3040                "parentid": "unfiled",
3041                "parentName": "Unfiled Bookmarks",
3042                "dateAdded": 1_381_542_355_843u64,
3043                "title": "Some query",
3044                "bmkUri": "place:tag=foo",
3045            },
3046            {
3047                "id": "unfiled",
3048                "type": "folder",
3049                "parentid": "places",
3050                "dateAdded": 1_381_542_355_843u64,
3051                "title": "Unfiled",
3052                "children": ["query1______"],
3053            }]),
3054            &BookmarkRootGuid::Unfiled.as_guid(),
3055            json!({"children" : [{"guid": "query1______", "url": "place:tag=foo"}]}),
3056        );
3057        let reader = api
3058            .open_connection(ConnectionType::ReadOnly)
3059            .expect("Should open read-only connection");
3060        assert!(
3061            frecency_stale_at(&reader, &Url::parse("place:tag=foo").unwrap())
3062                .expect("Should check stale frecency")
3063                .is_none(),
3064            "Should not mark frecency for queries as stale"
3065        );
3066    }
3067
3068    #[test]
3069    fn test_apply() -> Result<()> {
3070        let api = new_mem_api();
3071        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3072        let db = api.get_sync_connection().unwrap();
3073        let syncer = db.lock();
3074
3075        syncer
3076            .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
3077            .expect("should work");
3078
3079        insert_local_json_tree(
3080            &writer,
3081            json!({
3082                "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3083                "children": [
3084                    {
3085                        "guid": "bookmarkAAAA",
3086                        "title": "A",
3087                        "url": "http://example.com/a",
3088                    },
3089                    {
3090                        "guid": "bookmarkBBBB",
3091                        "title": "B",
3092                        "url": "http://example.com/b",
3093                    },
3094                ]
3095            }),
3096        );
3097        tags::tag_url(
3098            &writer,
3099            &Url::parse("http://example.com/a").expect("Should parse URL for A"),
3100            "baz",
3101        )
3102        .expect("Should tag A");
3103
3104        let records = vec![
3105            json!({
3106                "id": "bookmarkCCCC",
3107                "type": "bookmark",
3108                "parentid": "menu",
3109                "parentName": "menu",
3110                "dateAdded": 1_552_183_116_885u64,
3111                "title": "C",
3112                "bmkUri": "http://example.com/c",
3113                "tags": ["foo", "bar"],
3114            }),
3115            json!({
3116                "id": "menu",
3117                "type": "folder",
3118                "parentid": "places",
3119                "parentName": "",
3120                "dateAdded": 0,
3121                "title": "menu",
3122                "children": ["bookmarkCCCC"],
3123            }),
3124        ];
3125
3126        // Drop the sync connection to avoid a deadlock when the sync engine locks the mutex
3127        drop(syncer);
3128        let engine = create_sync_engine(&api);
3129
3130        let incoming = records
3131            .into_iter()
3132            .map(IncomingBso::from_test_content)
3133            .collect();
3134
3135        let mut outgoing = engine_apply_incoming(&engine, incoming);
3136        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3137        assert_eq!(
3138            outgoing
3139                .iter()
3140                .map(|p| p.envelope.id.as_str())
3141                .collect::<Vec<_>>(),
3142            vec!["bookmarkAAAA", "bookmarkBBBB", "unfiled",]
3143        );
3144        let record_for_a = outgoing
3145            .iter()
3146            .find(|p| p.envelope.id == "bookmarkAAAA")
3147            .expect("Should upload A");
3148        let content_for_a = record_for_a.to_test_incoming_t::<BookmarkRecord>();
3149        assert_eq!(content_for_a.tags, vec!["baz".to_string()]);
3150
3151        assert_local_json_tree(
3152            &writer,
3153            &BookmarkRootGuid::Root.as_guid(),
3154            json!({
3155                "guid": &BookmarkRootGuid::Root.as_guid(),
3156                "children": [
3157                    {
3158                        "guid": &BookmarkRootGuid::Menu.as_guid(),
3159                        "children": [
3160                            {
3161                                "guid": "bookmarkCCCC",
3162                                "title": "C",
3163                                "url": "http://example.com/c",
3164                                "date_added": Timestamp(1_552_183_116_885),
3165                            },
3166                        ],
3167                    },
3168                    {
3169                        "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3170                        "children": [],
3171                    },
3172                    {
3173                        "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3174                        "children": [
3175                            {
3176                                "guid": "bookmarkAAAA",
3177                                "title": "A",
3178                                "url": "http://example.com/a",
3179                            },
3180                            {
3181                                "guid": "bookmarkBBBB",
3182                                "title": "B",
3183                                "url": "http://example.com/b",
3184                            },
3185                        ],
3186                    },
3187                    {
3188                        "guid": &BookmarkRootGuid::Mobile.as_guid(),
3189                        "children": [],
3190                    },
3191                ],
3192            }),
3193        );
3194
3195        // We haven't finished the sync yet, so all local change counts for
3196        // items to upload should still be > 0.
3197        let guid_for_a: SyncGuid = "bookmarkAAAA".into();
3198        let info_for_a = get_raw_bookmark(&writer, &guid_for_a)
3199            .expect("Should fetch info for A")
3200            .unwrap();
3201        assert_eq!(info_for_a._sync_change_counter, 2);
3202        let info_for_unfiled = get_raw_bookmark(&writer, &BookmarkRootGuid::Unfiled.as_guid())
3203            .expect("Should fetch info for unfiled")
3204            .unwrap();
3205        assert_eq!(info_for_unfiled._sync_change_counter, 2);
3206
3207        engine
3208            .set_uploaded(
3209                ServerTimestamp(0),
3210                vec![
3211                    "bookmarkAAAA".into(),
3212                    "bookmarkBBBB".into(),
3213                    "unfiled".into(),
3214                ],
3215            )
3216            .expect("Should push synced changes back to the engine");
3217        engine.sync_finished().expect("finish always works");
3218
3219        let info_for_a = get_raw_bookmark(&writer, &guid_for_a)
3220            .expect("Should fetch info for A")
3221            .unwrap();
3222        assert_eq!(info_for_a._sync_change_counter, 0);
3223        let info_for_unfiled = get_raw_bookmark(&writer, &BookmarkRootGuid::Unfiled.as_guid())
3224            .expect("Should fetch info for unfiled")
3225            .unwrap();
3226        assert_eq!(info_for_unfiled._sync_change_counter, 0);
3227
3228        let mut tags_for_c = tags::get_tags_for_url(
3229            &writer,
3230            &Url::parse("http://example.com/c").expect("Should parse URL for C"),
3231        )
3232        .expect("Should return tags for C");
3233        tags_for_c.sort();
3234        assert_eq!(tags_for_c, &["bar", "foo"]);
3235
3236        Ok(())
3237    }
3238
3239    #[test]
3240    fn test_apply_invalid_url() -> Result<()> {
3241        let api = new_mem_api();
3242        let db = api.get_sync_connection().unwrap();
3243        let syncer = db.lock();
3244
3245        syncer
3246            .execute("UPDATE moz_bookmarks SET syncChangeCounter = 0", [])
3247            .expect("should work");
3248
3249        let records = vec![
3250            json!({
3251                "id": "bookmarkXXXX",
3252                "type": "bookmark",
3253                "parentid": "menu",
3254                "parentName": "menu",
3255                "dateAdded": 1_552_183_116_885u64,
3256                "title": "Invalid",
3257                "bmkUri": "invalid url",
3258            }),
3259            json!({
3260                "id": "menu",
3261                "type": "folder",
3262                "parentid": "places",
3263                "parentName": "",
3264                "dateAdded": 0,
3265                "title": "menu",
3266                "children": ["bookmarkXXXX"],
3267            }),
3268        ];
3269
3270        // Drop the sync connection to avoid a deadlock when the sync engine locks the mutex
3271        drop(syncer);
3272        let engine = create_sync_engine(&api);
3273
3274        let incoming = records
3275            .into_iter()
3276            .map(IncomingBso::from_test_content)
3277            .collect();
3278
3279        let mut outgoing = engine_apply_incoming(&engine, incoming);
3280        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3281        assert_eq!(
3282            outgoing
3283                .iter()
3284                .map(|p| p.envelope.id.as_str())
3285                .collect::<Vec<_>>(),
3286            vec!["bookmarkXXXX", "menu",]
3287        );
3288
3289        let record_for_invalid = outgoing
3290            .iter()
3291            .find(|p| p.envelope.id == "bookmarkXXXX")
3292            .expect("Should re-upload the invalid record");
3293
3294        assert!(
3295            matches!(
3296                record_for_invalid
3297                    .to_test_incoming()
3298                    .into_content::<BookmarkRecord>()
3299                    .kind,
3300                IncomingKind::Tombstone
3301            ),
3302            "is invalid record"
3303        );
3304
3305        let record_for_menu = outgoing
3306            .iter()
3307            .find(|p| p.envelope.id == "menu")
3308            .expect("Should upload menu");
3309        let content_for_menu = record_for_menu.to_test_incoming_t::<FolderRecord>();
3310        assert!(
3311            content_for_menu.children.is_empty(),
3312            "should have been removed from the parent"
3313        );
3314        Ok(())
3315    }
3316
3317    #[test]
3318    fn test_apply_tombstones() -> Result<()> {
3319        let local_modified = Timestamp::now();
3320        let api = new_mem_api();
3321        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3322        insert_local_json_tree(
3323            &writer,
3324            json!({
3325                "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3326                "children": [{
3327                    "guid": "bookmarkAAAA",
3328                    "title": "A",
3329                    "url": "http://example.com/a",
3330                    "date_added": local_modified,
3331                    "last_modified": local_modified,
3332                }, {
3333                    "guid": "separatorAAA",
3334                    "type": BookmarkType::Separator as u8,
3335                    "date_added": local_modified,
3336                    "last_modified": local_modified,
3337                }, {
3338                    "guid": "folderAAAAAA",
3339                    "children": [{
3340                        "guid": "bookmarkBBBB",
3341                        "title": "b",
3342                        "url": "http://example.com/b",
3343                        "date_added": local_modified,
3344                        "last_modified": local_modified,
3345                    }],
3346                }],
3347            }),
3348        );
3349        // a first sync, which will populate our mirror.
3350        let engine = create_sync_engine(&api);
3351        let outgoing = engine_apply_incoming(&engine, vec![]);
3352        let outgoing_ids = outgoing
3353            .iter()
3354            .map(|p| p.envelope.id.clone())
3355            .collect::<Vec<_>>();
3356        // 4 roots + 4 items
3357        assert_eq!(outgoing_ids.len(), 8, "{:?}", outgoing_ids);
3358
3359        engine
3360            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3361            .expect("should work");
3362        engine.sync_finished().expect("should work");
3363
3364        // Now the next sync with incoming tombstones.
3365        let remote_unfiled = json!({
3366            "id": "unfiled",
3367            "type": "folder",
3368            "parentid": "places",
3369            "title": "Unfiled",
3370            "children": [],
3371        });
3372
3373        let incoming = vec![
3374            IncomingBso::new_test_tombstone(Guid::new("bookmarkAAAA")),
3375            IncomingBso::new_test_tombstone(Guid::new("separatorAAA")),
3376            IncomingBso::new_test_tombstone(Guid::new("folderAAAAAA")),
3377            IncomingBso::new_test_tombstone(Guid::new("bookmarkBBBB")),
3378            IncomingBso::from_test_content(remote_unfiled),
3379        ];
3380
3381        let outgoing = engine_apply_incoming(&engine, incoming);
3382        let outgoing_ids = outgoing
3383            .iter()
3384            .map(|p| p.envelope.id.clone())
3385            .collect::<Vec<_>>();
3386        assert_eq!(outgoing_ids.len(), 0, "{:?}", outgoing_ids);
3387
3388        engine
3389            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3390            .expect("should work");
3391        engine.sync_finished().expect("should work");
3392
3393        // We deleted everything from unfiled.
3394        assert_local_json_tree(
3395            &api.get_sync_connection().unwrap().lock(),
3396            &BookmarkRootGuid::Unfiled.as_guid(),
3397            json!({"children" : []}),
3398        );
3399        Ok(())
3400    }
3401
3402    #[test]
3403    fn test_keywords() -> Result<()> {
3404        use crate::storage::bookmarks::bookmarks_get_url_for_keyword;
3405
3406        let api = new_mem_api();
3407        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3408
3409        let records = vec![
3410            json!({
3411                "id": "toolbar",
3412                "type": "folder",
3413                "parentid": "places",
3414                "parentName": "",
3415                "dateAdded": 0,
3416                "title": "toolbar",
3417                "children": ["bookmarkAAAA"],
3418            }),
3419            json!({
3420                "id": "bookmarkAAAA",
3421                "type": "bookmark",
3422                "parentid": "toolbar",
3423                "parentName": "toolbar",
3424                "dateAdded": 1_552_183_116_885u64,
3425                "title": "A",
3426                "bmkUri": "http://example.com/a/%s",
3427                "keyword": "a",
3428            }),
3429        ];
3430
3431        let engine = create_sync_engine(&api);
3432
3433        let incoming = records
3434            .into_iter()
3435            .map(IncomingBso::from_test_content)
3436            .collect();
3437
3438        let outgoing = engine_apply_incoming(&engine, incoming);
3439        let mut outgoing_ids = outgoing
3440            .iter()
3441            .map(|p| p.envelope.id.clone())
3442            .collect::<Vec<_>>();
3443        outgoing_ids.sort();
3444        assert_eq!(outgoing_ids, &["menu", "mobile", "toolbar", "unfiled"],);
3445
3446        assert_eq!(
3447            bookmarks_get_url_for_keyword(&writer, "a")?,
3448            Some(Url::parse("http://example.com/a/%s")?)
3449        );
3450
3451        engine
3452            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3453            .expect("Should push synced changes back to the engine");
3454        engine.sync_finished().expect("should work");
3455
3456        update_bookmark(
3457            &writer,
3458            &"bookmarkAAAA".into(),
3459            &UpdatableBookmark {
3460                title: Some("A (local)".into()),
3461                ..UpdatableBookmark::default()
3462            }
3463            .into(),
3464        )?;
3465
3466        let outgoing = engine_apply_incoming(&engine, vec![]);
3467        assert_eq!(outgoing.len(), 1);
3468        let bk = outgoing[0].to_test_incoming_t::<BookmarkRecord>();
3469        assert_eq!(bk.record_id.as_guid(), "bookmarkAAAA");
3470        assert_eq!(bk.keyword.unwrap(), "a");
3471        assert_eq!(bk.url.unwrap(), "http://example.com/a/%s");
3472
3473        // URLs with keywords should have a foreign count of 3 (one for the
3474        // local bookmark, one for the synced bookmark, and one for the
3475        // keyword), and we shouldn't allow deleting them until the keyword
3476        // is removed.
3477        let foreign_count = writer
3478            .try_query_row(
3479                "SELECT foreign_count FROM moz_places
3480             WHERE url_hash = hash(:url) AND
3481                   url = :url",
3482                &[(":url", &"http://example.com/a/%s")],
3483                |row| -> rusqlite::Result<_> { row.get::<_, i64>(0) },
3484                false,
3485            )?
3486            .expect("Should fetch foreign count for URL A");
3487        assert_eq!(foreign_count, 3);
3488        let err = writer
3489            .execute(
3490                "DELETE FROM moz_places
3491             WHERE url_hash = hash(:url) AND
3492                   url = :url",
3493                rusqlite::named_params! {
3494                    ":url": "http://example.com/a/%s",
3495                },
3496            )
3497            .expect_err("Should fail to delete URL A with keyword");
3498        match err {
3499            RusqlError::SqliteFailure(e, _) => assert_eq!(e.code, ErrorCode::ConstraintViolation),
3500            _ => panic!("Wanted constraint violation error; got {:?}", err),
3501        }
3502
3503        Ok(())
3504    }
3505
3506    #[test]
3507    fn test_apply_complex_bookmark_keywords() -> Result<()> {
3508        use crate::storage::bookmarks::bookmarks_get_url_for_keyword;
3509
3510        // We don't provide an API for setting keywords locally, but we'll
3511        // still round-trip and fix up keywords on the server.
3512
3513        let api = new_mem_api();
3514        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3515
3516        // Let's add some remote bookmarks with keywords.
3517        let remote_records = json!([{
3518            // A1 and A2 have the same URL and keyword, so we shouldn't
3519            // reupload them.
3520            "id": "bookmarkAAA1",
3521            "type": "bookmark",
3522            "parentid": "unfiled",
3523            "parentName": "Unfiled",
3524            "title": "A1",
3525            "bmkUri": "http://example.com/a",
3526            "keyword": "one",
3527        }, {
3528            "id": "bookmarkAAA2",
3529            "type": "bookmark",
3530            "parentid": "menu",
3531            "parentName": "Menu",
3532            "title": "A2",
3533            "bmkUri": "http://example.com/a",
3534            "keyword": "one",
3535        }, {
3536            // B1 and B2 have mismatched keywords, and we should reupload
3537            // both of them. It's not specified which keyword wins, but
3538            // reuploading both means we make them consistent.
3539            "id": "bookmarkBBB1",
3540            "type": "bookmark",
3541            "parentid": "unfiled",
3542            "parentName": "Unfiled",
3543            "title": "B1",
3544            "bmkUri": "http://example.com/b",
3545            "keyword": "two",
3546        }, {
3547            "id": "bookmarkBBB2",
3548            "type": "bookmark",
3549            "parentid": "menu",
3550            "parentName": "Menu",
3551            "title": "B2",
3552            "bmkUri": "http://example.com/b",
3553            "keyword": "three",
3554        }, {
3555            // C1 has a keyword; C2 doesn't. As with B, which one wins
3556            // depends on which record we apply last, and how SQLite
3557            // processes the rows, but we should reupload both.
3558            "id": "bookmarkCCC1",
3559            "type": "bookmark",
3560            "parentid": "unfiled",
3561            "parentName": "Unfiled",
3562            "title": "C1",
3563            "bmkUri": "http://example.com/c",
3564            "keyword": "four",
3565        }, {
3566            "id": "bookmarkCCC2",
3567            "type": "bookmark",
3568            "parentid": "menu",
3569            "parentName": "Menu",
3570            "title": "C2",
3571            "bmkUri": "http://example.com/c",
3572        }, {
3573            // D has a keyword that needs to be cleaned up before
3574            // inserting. In this case, we intentionally don't reupload.
3575            "id": "bookmarkDDDD",
3576            "type": "bookmark",
3577            "parentid": "unfiled",
3578            "parentName": "Unfiled",
3579            "title": "D",
3580            "bmkUri": "http://example.com/d",
3581            "keyword": " FIVE ",
3582        }, {
3583            "id": "unfiled",
3584            "type": "folder",
3585            "parentid": "places",
3586            "title": "Unfiled",
3587            "children": ["bookmarkAAA1", "bookmarkBBB1", "bookmarkCCC1", "bookmarkDDDD"],
3588        }, {
3589            "id": "menu",
3590            "type": "folder",
3591            "parentid": "places",
3592            "title": "Menu",
3593            "children": ["bookmarkAAA2", "bookmarkBBB2", "bookmarkCCC2"],
3594        }]);
3595
3596        let engine = create_sync_engine(&api);
3597        let incoming = if let Value::Array(records) = remote_records {
3598            records
3599                .into_iter()
3600                .map(IncomingBso::from_test_content)
3601                .collect()
3602        } else {
3603            unreachable!("JSON records must be an array");
3604        };
3605        let mut outgoing = engine_apply_incoming(&engine, incoming);
3606        outgoing.sort_by(|a, b| a.envelope.id.cmp(&b.envelope.id));
3607
3608        assert_local_json_tree(
3609            &writer,
3610            &BookmarkRootGuid::Root.as_guid(),
3611            json!({
3612                "guid": &BookmarkRootGuid::Root.as_guid(),
3613                "children": [{
3614                    "guid": &BookmarkRootGuid::Menu.as_guid(),
3615                    "children": [{
3616                        "guid": "bookmarkAAA2",
3617                        "title": "A2",
3618                        "url": "http://example.com/a",
3619                    }, {
3620                        "guid": "bookmarkBBB2",
3621                        "title": "B2",
3622                        "url": "http://example.com/b",
3623                    }, {
3624                        "guid": "bookmarkCCC2",
3625                        "title": "C2",
3626                        "url": "http://example.com/c",
3627                    }],
3628                }, {
3629                    "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3630                    "children": [],
3631                }, {
3632                    "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3633                    "children": [{
3634                        "guid": "bookmarkAAA1",
3635                        "title": "A1",
3636                        "url": "http://example.com/a",
3637                    }, {
3638                        "guid": "bookmarkBBB1",
3639                        "title": "B1",
3640                        "url": "http://example.com/b",
3641                    }, {
3642                        "guid": "bookmarkCCC1",
3643                        "title": "C1",
3644                        "url": "http://example.com/c",
3645                    }, {
3646                        "guid": "bookmarkDDDD",
3647                        "title": "D",
3648                        "url": "http://example.com/d",
3649                    }],
3650                }, {
3651                    "guid": &BookmarkRootGuid::Mobile.as_guid(),
3652                    "children": [],
3653                }],
3654            }),
3655        );
3656        // And verify our local keywords are correct, too.
3657        let url_for_one = bookmarks_get_url_for_keyword(&writer, "one")?
3658            .expect("Should have URL for keyword `one`");
3659        assert_eq!(url_for_one.as_str(), "http://example.com/a");
3660
3661        let keyword_for_b = match (
3662            bookmarks_get_url_for_keyword(&writer, "two")?,
3663            bookmarks_get_url_for_keyword(&writer, "three")?,
3664        ) {
3665            (Some(url), None) => {
3666                assert_eq!(url.as_str(), "http://example.com/b");
3667                "two".to_string()
3668            }
3669            (None, Some(url)) => {
3670                assert_eq!(url.as_str(), "http://example.com/b");
3671                "three".to_string()
3672            }
3673            (Some(_), Some(_)) => panic!("Should pick `two` or `three`, not both"),
3674            (None, None) => panic!("Should have URL for either `two` or `three`"),
3675        };
3676
3677        let keyword_for_c = match bookmarks_get_url_for_keyword(&writer, "four")? {
3678            Some(url) => {
3679                assert_eq!(url.as_str(), "http://example.com/c");
3680                Some("four".to_string())
3681            }
3682            None => None,
3683        };
3684
3685        let url_for_five = bookmarks_get_url_for_keyword(&writer, "five")?
3686            .expect("Should have URL for keyword `five`");
3687        assert_eq!(url_for_five.as_str(), "http://example.com/d");
3688
3689        let expected_outgoing_keywords = &[
3690            ("bookmarkBBB1", Some(keyword_for_b.clone())),
3691            ("bookmarkBBB2", Some(keyword_for_b.clone())),
3692            ("bookmarkCCC1", keyword_for_c.clone()),
3693            ("bookmarkCCC2", keyword_for_c.clone()),
3694            ("menu", None), // Roots always get uploaded on the first sync.
3695            ("mobile", None),
3696            ("toolbar", None),
3697            ("unfiled", None),
3698        ];
3699        assert_eq!(
3700            outgoing
3701                .iter()
3702                .map(|p| (
3703                    p.envelope.id.as_str(),
3704                    p.to_test_incoming_t::<BookmarkRecord>().keyword
3705                ))
3706                .collect::<Vec<_>>(),
3707            expected_outgoing_keywords,
3708            "Should upload new bookmarks and fix up keywords",
3709        );
3710
3711        // Now push the records back to the engine, so we can check what we're
3712        // uploading.
3713        engine
3714            .set_uploaded(
3715                ServerTimestamp(0),
3716                expected_outgoing_keywords
3717                    .iter()
3718                    .map(|(id, _)| SyncGuid::from(id))
3719                    .collect(),
3720            )
3721            .expect("Should push synced changes back to the engine");
3722        engine.sync_finished().expect("should work");
3723
3724        let mut synced_item_for_b = SyncedBookmarkItem::new();
3725        synced_item_for_b
3726            .validity(SyncedBookmarkValidity::Valid)
3727            .kind(SyncedBookmarkKind::Bookmark)
3728            .url(Some("http://example.com/b"))
3729            .keyword(Some(&keyword_for_b));
3730        let mut synced_item_for_c = SyncedBookmarkItem::new();
3731        synced_item_for_c
3732            .validity(SyncedBookmarkValidity::Valid)
3733            .kind(SyncedBookmarkKind::Bookmark)
3734            .url(Some("http://example.com/c"))
3735            .keyword(Some(keyword_for_c.unwrap().as_str()));
3736        let expected_synced_items = &[
3737            ExpectedSyncedItem::with_properties("bookmarkBBB1", &synced_item_for_b, |a| {
3738                a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
3739                    .title(Some("B1"))
3740            }),
3741            ExpectedSyncedItem::with_properties("bookmarkBBB2", &synced_item_for_b, |a| {
3742                a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
3743                    .title(Some("B2"))
3744            }),
3745            ExpectedSyncedItem::with_properties("bookmarkCCC1", &synced_item_for_c, |a| {
3746                a.parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
3747                    .title(Some("C1"))
3748            }),
3749            ExpectedSyncedItem::with_properties("bookmarkCCC2", &synced_item_for_c, |a| {
3750                a.parent_guid(Some(&BookmarkRootGuid::Menu.as_guid()))
3751                    .title(Some("C2"))
3752            }),
3753        ];
3754        for item in expected_synced_items {
3755            item.check(&writer)?;
3756        }
3757
3758        Ok(())
3759    }
3760
3761    #[test]
3762    fn test_wipe() -> Result<()> {
3763        let api = new_mem_api();
3764        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3765
3766        let records = vec![
3767            json!({
3768                "id": "toolbar",
3769                "type": "folder",
3770                "parentid": "places",
3771                "parentName": "",
3772                "dateAdded": 0,
3773                "title": "toolbar",
3774                "children": ["folderAAAAAA"],
3775            }),
3776            json!({
3777                "id": "folderAAAAAA",
3778                "type": "folder",
3779                "parentid": "toolbar",
3780                "parentName": "toolbar",
3781                "dateAdded": 0,
3782                "title": "A",
3783                "children": ["bookmarkBBBB"],
3784            }),
3785            json!({
3786                "id": "bookmarkBBBB",
3787                "type": "bookmark",
3788                "parentid": "folderAAAAAA",
3789                "parentName": "A",
3790                "dateAdded": 0,
3791                "title": "A",
3792                "bmkUri": "http://example.com/a",
3793            }),
3794            json!({
3795                "id": "menu",
3796                "type": "folder",
3797                "parentid": "places",
3798                "parentName": "",
3799                "dateAdded": 0,
3800                "title": "menu",
3801                "children": ["folderCCCCCC"],
3802            }),
3803            json!({
3804                "id": "folderCCCCCC",
3805                "type": "folder",
3806                "parentid": "menu",
3807                "parentName": "menu",
3808                "dateAdded": 0,
3809                "title": "A",
3810                "children": ["bookmarkDDDD", "folderEEEEEE"],
3811            }),
3812            json!({
3813                "id": "bookmarkDDDD",
3814                "type": "bookmark",
3815                "parentid": "folderCCCCCC",
3816                "parentName": "C",
3817                "dateAdded": 0,
3818                "title": "D",
3819                "bmkUri": "http://example.com/d",
3820            }),
3821            json!({
3822                "id": "folderEEEEEE",
3823                "type": "folder",
3824                "parentid": "folderCCCCCC",
3825                "parentName": "C",
3826                "dateAdded": 0,
3827                "title": "E",
3828                "children": ["bookmarkFFFF"],
3829            }),
3830            json!({
3831                "id": "bookmarkFFFF",
3832                "type": "bookmark",
3833                "parentid": "folderEEEEEE",
3834                "parentName": "E",
3835                "dateAdded": 0,
3836                "title": "F",
3837                "bmkUri": "http://example.com/f",
3838            }),
3839        ];
3840
3841        let engine = create_sync_engine(&api);
3842
3843        let incoming = records
3844            .into_iter()
3845            .map(IncomingBso::from_test_content)
3846            .collect();
3847
3848        let outgoing = engine_apply_incoming(&engine, incoming);
3849        let mut outgoing_ids = outgoing
3850            .iter()
3851            .map(|p| p.envelope.id.clone())
3852            .collect::<Vec<_>>();
3853        outgoing_ids.sort();
3854        assert_eq!(outgoing_ids, &["menu", "mobile", "toolbar", "unfiled"],);
3855
3856        engine
3857            .set_uploaded(ServerTimestamp(0), outgoing_ids)
3858            .expect("Should push synced changes back to the engine");
3859        engine.sync_finished().expect("should work");
3860
3861        engine.wipe().expect("Should wipe the store");
3862
3863        // Wiping the store should delete all items except for the roots.
3864        assert_local_json_tree(
3865            &writer,
3866            &BookmarkRootGuid::Root.as_guid(),
3867            json!({
3868                "guid": &BookmarkRootGuid::Root.as_guid(),
3869                "children": [
3870                    {
3871                        "guid": &BookmarkRootGuid::Menu.as_guid(),
3872                        "children": [],
3873                    },
3874                    {
3875                        "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3876                        "children": [],
3877                    },
3878                    {
3879                        "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3880                        "children": [],
3881                    },
3882                    {
3883                        "guid": &BookmarkRootGuid::Mobile.as_guid(),
3884                        "children": [],
3885                    },
3886                ],
3887            }),
3888        );
3889
3890        // Now pretend that F changed remotely between the time we called `wipe`
3891        // and the next sync.
3892        let record_for_f = json!({
3893            "id": "bookmarkFFFF",
3894            "type": "bookmark",
3895            "parentid": "folderEEEEEE",
3896            "parentName": "E",
3897            "dateAdded": 0,
3898            "title": "F (remote)",
3899            "bmkUri": "http://example.com/f-remote",
3900        });
3901
3902        let incoming = vec![IncomingBso::from_test_content_ts(
3903            record_for_f,
3904            ServerTimestamp(1000),
3905        )];
3906
3907        let outgoing = engine_apply_incoming(&engine, incoming);
3908        let (outgoing_tombstones, outgoing_records): (Vec<_>, Vec<_>) =
3909            outgoing.iter().partition(|record| {
3910                matches!(
3911                    record
3912                        .to_test_incoming()
3913                        .into_content::<BookmarkRecord>()
3914                        .kind,
3915                    IncomingKind::Tombstone
3916                )
3917            });
3918        let mut outgoing_record_ids = outgoing_records
3919            .iter()
3920            .map(|p| p.envelope.id.as_str())
3921            .collect::<Vec<_>>();
3922        outgoing_record_ids.sort_unstable();
3923        assert_eq!(
3924            outgoing_record_ids,
3925            &["bookmarkFFFF", "menu", "mobile", "toolbar", "unfiled"],
3926        );
3927        let mut outgoing_tombstone_ids = outgoing_tombstones
3928            .iter()
3929            .map(|p| p.envelope.id.clone())
3930            .collect::<Vec<_>>();
3931        outgoing_tombstone_ids.sort();
3932        assert_eq!(
3933            outgoing_tombstone_ids,
3934            &[
3935                "bookmarkBBBB",
3936                "bookmarkDDDD",
3937                "folderAAAAAA",
3938                "folderCCCCCC",
3939                "folderEEEEEE"
3940            ]
3941        );
3942
3943        // F should move to the closest surviving ancestor, which, in this case,
3944        // is the menu.
3945        assert_local_json_tree(
3946            &writer,
3947            &BookmarkRootGuid::Root.as_guid(),
3948            json!({
3949                "guid": &BookmarkRootGuid::Root.as_guid(),
3950                "children": [
3951                    {
3952                        "guid": &BookmarkRootGuid::Menu.as_guid(),
3953                        "children": [
3954                            {
3955                                "guid": "bookmarkFFFF",
3956                                "title": "F (remote)",
3957                                "url": "http://example.com/f-remote",
3958                            },
3959                        ],
3960                    },
3961                    {
3962                        "guid": &BookmarkRootGuid::Toolbar.as_guid(),
3963                        "children": [],
3964                    },
3965                    {
3966                        "guid": &BookmarkRootGuid::Unfiled.as_guid(),
3967                        "children": [],
3968                    },
3969                    {
3970                        "guid": &BookmarkRootGuid::Mobile.as_guid(),
3971                        "children": [],
3972                    },
3973                ],
3974            }),
3975        );
3976
3977        Ok(())
3978    }
3979
3980    #[test]
3981    fn test_reset() -> anyhow::Result<()> {
3982        let api = new_mem_api();
3983        let writer = api.open_connection(ConnectionType::ReadWrite)?;
3984
3985        insert_local_json_tree(
3986            &writer,
3987            json!({
3988                "guid": &BookmarkRootGuid::Menu.as_guid(),
3989                "children": [
3990                    {
3991                        "guid": "bookmark2___",
3992                        "title": "2",
3993                        "url": "http://example.com/2",
3994                    }
3995                ],
3996            }),
3997        );
3998
3999        {
4000            // scope to kill our sync connection.
4001            let engine = create_sync_engine(&api);
4002
4003            assert_eq!(
4004                engine.get_sync_assoc()?,
4005                EngineSyncAssociation::Disconnected
4006            );
4007
4008            let outgoing = engine_apply_incoming(&engine, vec![]);
4009            let synced_ids: Vec<Guid> = outgoing.into_iter().map(|c| c.envelope.id).collect();
4010            assert_eq!(synced_ids.len(), 5, "should be 4 roots + 1 outgoing item");
4011            engine.set_uploaded(ServerTimestamp(2_000), synced_ids)?;
4012            engine.sync_finished().expect("should work");
4013
4014            let db = api.get_sync_connection().unwrap();
4015            let syncer = db.lock();
4016            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(2_000));
4017
4018            let sync_ids = CollSyncIds {
4019                global: Guid::random(),
4020                coll: Guid::random(),
4021            };
4022            // Temporarily drop the sync connection to avoid a deadlock when the sync engine locks
4023            // the mutex
4024            drop(syncer);
4025            engine.reset(&EngineSyncAssociation::Connected(sync_ids.clone()))?;
4026            let syncer = db.lock();
4027            assert_eq!(
4028                get_meta::<Guid>(&syncer, GLOBAL_SYNCID_META_KEY)?,
4029                Some(sync_ids.global)
4030            );
4031            assert_eq!(
4032                get_meta::<Guid>(&syncer, COLLECTION_SYNCID_META_KEY)?,
4033                Some(sync_ids.coll)
4034            );
4035            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(0));
4036        }
4037        // do it all again - after the reset we should get the same results.
4038        {
4039            let engine = create_sync_engine(&api);
4040
4041            let outgoing = engine_apply_incoming(&engine, vec![]);
4042            let synced_ids: Vec<Guid> = outgoing.into_iter().map(|c| c.envelope.id).collect();
4043            assert_eq!(synced_ids.len(), 5, "should be 4 roots + 1 outgoing item");
4044            engine.set_uploaded(ServerTimestamp(2_000), synced_ids)?;
4045            engine.sync_finished().expect("should work");
4046
4047            let db = api.get_sync_connection().unwrap();
4048            let syncer = db.lock();
4049            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(2_000));
4050
4051            // Temporarily drop the sync connection to avoid a deadlock when the sync engine locks
4052            // the mutex
4053            drop(syncer);
4054            engine.reset(&EngineSyncAssociation::Disconnected)?;
4055            let syncer = db.lock();
4056            assert_eq!(
4057                get_meta::<Option<String>>(&syncer, GLOBAL_SYNCID_META_KEY)?,
4058                None
4059            );
4060            assert_eq!(
4061                get_meta::<Option<String>>(&syncer, COLLECTION_SYNCID_META_KEY)?,
4062                None
4063            );
4064            assert_eq!(get_meta::<i64>(&syncer, LAST_SYNC_META_KEY)?, Some(0));
4065        }
4066
4067        Ok(())
4068    }
4069
4070    #[test]
4071    fn test_incoming_timestamps() -> anyhow::Result<()> {
4072        let api = new_mem_api();
4073        let reader = api.open_connection(ConnectionType::ReadOnly)?;
4074
4075        // The timestamp of each record on the server. We expect this to be our "last modified".
4076        let remote_modified = ServerTimestamp::from_millis(1770000000000);
4077
4078        let records = vec![
4079            json!({
4080                "id": "toolbar",
4081                "type": "folder",
4082                "parentid": "places",
4083                "parentName": "",
4084                "dateAdded": 0,
4085                "title": "toolbar",
4086                "children": ["folderAAAAAA"],
4087            }),
4088            json!({
4089                "id": "folderAAAAAA",
4090                "type": "folder",
4091                "parentid": "toolbar",
4092                "parentName": "toolbar",
4093                "dateAdded": 0,
4094                "title": "A",
4095                "children": ["bookmarkBBBB"],
4096            }),
4097            json!({
4098                "id": "bookmarkBBBB",
4099                "type": "bookmark",
4100                "parentid": "folderAAAAAA",
4101                "parentName": "A",
4102                "dateAdded": 0,
4103                "title": "A",
4104                "bmkUri": "http://example.com/a",
4105            }),
4106        ];
4107
4108        let engine = create_sync_engine(&api);
4109
4110        let incoming = records
4111            .clone()
4112            .into_iter()
4113            .map(|json| IncomingBso::from_test_content_ts(json, remote_modified))
4114            .collect();
4115
4116        engine_apply_incoming(&engine, incoming);
4117
4118        // This was the first creation of the bookmark, the lastModified should be the server timestamp.
4119        let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4120            .expect("must work")
4121            .expect("must exist");
4122
4123        assert_eq!(
4124            bm.date_modified.as_millis_i64(),
4125            remote_modified.as_millis()
4126        );
4127
4128        // Now reset the engine and do it again, which should not adjust date_modified.
4129        engine.reset(&EngineSyncAssociation::Disconnected)?;
4130        let incoming = records
4131            .into_iter()
4132            .map(|json| IncomingBso::from_test_content_ts(json, remote_modified))
4133            .collect();
4134        engine_apply_incoming(&engine, incoming);
4135        let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4136            .expect("must work")
4137            .expect("must exist");
4138
4139        assert_eq!(
4140            bm.date_modified.as_millis_i64(),
4141            remote_modified.as_millis()
4142        );
4143
4144        // applying a change to the bookmark should update it.
4145        let new_records = vec![json!({
4146            "id": "bookmarkBBBB",
4147            "type": "bookmark",
4148            "parentid": "folderAAAAAA",
4149            "parentName": "A",
4150            "dateAdded": 0,
4151            "title": "A",
4152            "bmkUri": "http://example.com/a",
4153        })];
4154        let new_remote_modified = ServerTimestamp::from_millis(1780000000000);
4155        let new_incoming = new_records
4156            .into_iter()
4157            .map(|json| IncomingBso::from_test_content_ts(json, new_remote_modified))
4158            .collect();
4159        engine_apply_incoming(&engine, new_incoming);
4160        let bm = get_raw_bookmark(&reader, &SyncGuid::new("bookmarkBBBB"))
4161            .expect("must work")
4162            .expect("must exist");
4163
4164        assert_eq!(
4165            bm.date_modified.as_millis_i64(),
4166            new_remote_modified.as_millis()
4167        );
4168        Ok(())
4169    }
4170
4171    #[test]
4172    fn test_dedupe_local_newer() -> anyhow::Result<()> {
4173        let api = new_mem_api();
4174        let writer = api.open_connection(ConnectionType::ReadWrite)?;
4175
4176        let local_modified = Timestamp::now();
4177        let remote_modified = local_modified.as_millis() as f64 / 1000f64 - 5f64;
4178
4179        // Start with merged items.
4180        apply_incoming(
4181            &api,
4182            ServerTimestamp::from_float_seconds(remote_modified),
4183            json!([{
4184                "id": "menu",
4185                "type": "folder",
4186                "parentid": "places",
4187                "parentName": "",
4188                "title": "menu",
4189                "children": ["bookmarkAAA5"],
4190            }, {
4191                "id": "bookmarkAAA5",
4192                "type": "bookmark",
4193                "parentid": "menu",
4194                "parentName": "menu",
4195                "title": "A",
4196                "bmkUri": "http://example.com/a",
4197            }]),
4198        );
4199
4200        // Add newer local dupes.
4201        insert_local_json_tree(
4202            &writer,
4203            json!({
4204                "guid": &BookmarkRootGuid::Menu.as_guid(),
4205                "children": [{
4206                    "guid": "bookmarkAAA1",
4207                    "title": "A",
4208                    "url": "http://example.com/a",
4209                    "date_added": local_modified,
4210                    "last_modified": local_modified,
4211                }, {
4212                    "guid": "bookmarkAAA2",
4213                    "title": "A",
4214                    "url": "http://example.com/a",
4215                    "date_added": local_modified,
4216                    "last_modified": local_modified,
4217                }, {
4218                    "guid": "bookmarkAAA3",
4219                    "title": "A",
4220                    "url": "http://example.com/a",
4221                    "date_added": local_modified,
4222                    "last_modified": local_modified,
4223                }],
4224            }),
4225        );
4226
4227        // Add older remote dupes.
4228        apply_incoming(
4229            &api,
4230            ServerTimestamp(local_modified.as_millis() as i64),
4231            json!([{
4232                "id": "menu",
4233                "type": "folder",
4234                "parentid": "places",
4235                "parentName": "",
4236                "title": "menu",
4237                "children": ["bookmarkAAAA", "bookmarkAAA4", "bookmarkAAA5"],
4238            }, {
4239                "id": "bookmarkAAAA",
4240                "type": "bookmark",
4241                "parentid": "menu",
4242                "parentName": "menu",
4243                "title": "A",
4244                "bmkUri": "http://example.com/a",
4245            }, {
4246                "id": "bookmarkAAA4",
4247                "type": "bookmark",
4248                "parentid": "menu",
4249                "parentName": "menu",
4250                "title": "A",
4251                "bmkUri": "http://example.com/a",
4252            }]),
4253        );
4254
4255        assert_local_json_tree(
4256            &writer,
4257            &BookmarkRootGuid::Menu.as_guid(),
4258            json!({
4259                "guid": &BookmarkRootGuid::Menu.as_guid(),
4260                "children": [{
4261                    "guid": "bookmarkAAAA",
4262                    "title": "A",
4263                    "url": "http://example.com/a",
4264                }, {
4265                    "guid": "bookmarkAAA4",
4266                    "title": "A",
4267                    "url": "http://example.com/a",
4268                }, {
4269                    "guid": "bookmarkAAA5",
4270                    "title": "A",
4271                    "url": "http://example.com/a",
4272                }, {
4273                    "guid": "bookmarkAAA3",
4274                    "title": "A",
4275                    "url": "http://example.com/a",
4276                }],
4277            }),
4278        );
4279
4280        Ok(())
4281    }
4282
4283    #[test]
4284    fn test_deduping_remote_newer() -> anyhow::Result<()> {
4285        let api = new_mem_api();
4286        let writer = api.open_connection(ConnectionType::ReadWrite)?;
4287
4288        let local_modified = Timestamp::from(Timestamp::now().as_millis() - 5000);
4289        let remote_modified = local_modified.as_millis() as f64 / 1000f64;
4290
4291        // Start with merged items.
4292        apply_incoming(
4293            &api,
4294            ServerTimestamp::from_float_seconds(remote_modified),
4295            json!([{
4296                "id": "menu",
4297                "type": "folder",
4298                "parentid": "places",
4299                "parentName": "",
4300                "title": "menu",
4301                "children": ["folderAAAAAA"],
4302            }, {
4303                // Shouldn't dedupe to `folderA11111` because it's been applied.
4304                "id": "folderAAAAAA",
4305                "type": "folder",
4306                "parentid": "menu",
4307                "parentName": "menu",
4308                "title": "A",
4309                "children": ["bookmarkGGGG"],
4310            }, {
4311                // Shouldn't dedupe to `bookmarkG111`.
4312                "id": "bookmarkGGGG",
4313                "type": "bookmark",
4314                "parentid": "folderAAAAAA",
4315                "parentName": "A",
4316                "title": "G",
4317                "bmkUri": "http://example.com/g",
4318            }]),
4319        );
4320
4321        // Add older local dupes.
4322        insert_local_json_tree(
4323            &writer,
4324            json!({
4325                "guid": "folderAAAAAA",
4326                "children": [{
4327                    // Not a candidate for `bookmarkH111` because we didn't dupe `folderAAAAAA`.
4328                    "guid": "bookmarkHHHH",
4329                    "title": "H",
4330                    "url": "http://example.com/h",
4331                    "date_added": local_modified,
4332                    "last_modified": local_modified,
4333                }]
4334            }),
4335        );
4336        insert_local_json_tree(
4337            &writer,
4338            json!({
4339                "guid": &BookmarkRootGuid::Menu.as_guid(),
4340                "children": [{
4341                    // Should dupe to `folderB11111`.
4342                    "guid": "folderBBBBBB",
4343                    "type": BookmarkType::Folder as u8,
4344                    "title": "B",
4345                    "date_added": local_modified,
4346                    "last_modified": local_modified,
4347                    "children": [{
4348                        // Should dupe to `bookmarkC222`.
4349                        "guid": "bookmarkC111",
4350                        "title": "C",
4351                        "url": "http://example.com/c",
4352                        "date_added": local_modified,
4353                        "last_modified": local_modified,
4354                    }, {
4355                        // Should dupe to `separatorF11` because the positions are the same.
4356                        "guid": "separatorFFF",
4357                        "type": BookmarkType::Separator as u8,
4358                        "date_added": local_modified,
4359                        "last_modified": local_modified,
4360                    }],
4361                }, {
4362                    // Shouldn't dupe to `separatorE11`, because the positions are different.
4363                    "guid": "separatorEEE",
4364                    "type": BookmarkType::Separator as u8,
4365                    "date_added": local_modified,
4366                    "last_modified": local_modified,
4367                }, {
4368                    // Shouldn't dupe to `bookmarkC222` because the parents are different.
4369                    "guid": "bookmarkCCCC",
4370                    "title": "C",
4371                    "url": "http://example.com/c",
4372                    "date_added": local_modified,
4373                    "last_modified": local_modified,
4374                }, {
4375                    // Should dupe to `queryD111111`.
4376                    "guid": "queryDDDDDDD",
4377                    "title": "Most Visited",
4378                    "url": "place:maxResults=10&sort=8",
4379                    "date_added": local_modified,
4380                    "last_modified": local_modified,
4381                }],
4382            }),
4383        );
4384
4385        // Add newer remote items.
4386        apply_incoming(
4387            &api,
4388            ServerTimestamp::from_float_seconds(remote_modified),
4389            json!([{
4390                "id": "menu",
4391                "type": "folder",
4392                "parentid": "places",
4393                "parentName": "",
4394                "title": "menu",
4395                "children": ["folderAAAAAA", "folderB11111", "folderA11111", "separatorE11", "queryD111111"],
4396                "dateAdded": local_modified.as_millis(),
4397            }, {
4398                "id": "folderB11111",
4399                "type": "folder",
4400                "parentid": "menu",
4401                "parentName": "menu",
4402                "title": "B",
4403                "children": ["bookmarkC222", "separatorF11"],
4404                "dateAdded": local_modified.as_millis(),
4405            }, {
4406                "id": "bookmarkC222",
4407                "type": "bookmark",
4408                "parentid": "folderB11111",
4409                "parentName": "B",
4410                "title": "C",
4411                "bmkUri": "http://example.com/c",
4412                "dateAdded": local_modified.as_millis(),
4413            }, {
4414                "id": "separatorF11",
4415                "type": "separator",
4416                "parentid": "folderB11111",
4417                "parentName": "B",
4418                "dateAdded": local_modified.as_millis(),
4419            }, {
4420                "id": "folderA11111",
4421                "type": "folder",
4422                "parentid": "menu",
4423                "parentName": "menu",
4424                "title": "A",
4425                "children": ["bookmarkG111"],
4426                "dateAdded": local_modified.as_millis(),
4427            }, {
4428                "id": "bookmarkG111",
4429                "type": "bookmark",
4430                "parentid": "folderA11111",
4431                "parentName": "A",
4432                "title": "G",
4433                "bmkUri": "http://example.com/g",
4434                "dateAdded": local_modified.as_millis(),
4435            }, {
4436                "id": "separatorE11",
4437                "type": "separator",
4438                "parentid": "folderB11111",
4439                "parentName": "B",
4440                "dateAdded": local_modified.as_millis(),
4441            }, {
4442                "id": "queryD111111",
4443                "type": "query",
4444                "parentid": "menu",
4445                "parentName": "menu",
4446                "title": "Most Visited",
4447                "bmkUri": "place:maxResults=10&sort=8",
4448                "dateAdded": local_modified.as_millis(),
4449            }]),
4450        );
4451
4452        assert_local_json_tree(
4453            &writer,
4454            &BookmarkRootGuid::Menu.as_guid(),
4455            json!({
4456                "guid": &BookmarkRootGuid::Menu.as_guid(),
4457                "children": [{
4458                    "guid": "folderAAAAAA",
4459                    "children": [{
4460                        "guid": "bookmarkGGGG",
4461                        "title": "G",
4462                        "url": "http://example.com/g",
4463                    }, {
4464                        "guid": "bookmarkHHHH",
4465                        "title": "H",
4466                        "url": "http://example.com/h",
4467                    }]
4468                }, {
4469                    "guid": "folderB11111",
4470                    "children": [{
4471                        "guid": "bookmarkC222",
4472                        "title": "C",
4473                        "url": "http://example.com/c",
4474                    }, {
4475                        "guid": "separatorF11",
4476                        "type": BookmarkType::Separator as u8,
4477                    }],
4478                }, {
4479                    "guid": "folderA11111",
4480                    "children": [{
4481                        "guid": "bookmarkG111",
4482                        "title": "G",
4483                        "url": "http://example.com/g",
4484                    }]
4485                }, {
4486                    "guid": "separatorE11",
4487                    "type": BookmarkType::Separator as u8,
4488                }, {
4489                    "guid": "queryD111111",
4490                    "title": "Most Visited",
4491                    "url": "place:maxResults=10&sort=8",
4492                }, {
4493                    "guid": "separatorEEE",
4494                    "type": BookmarkType::Separator as u8,
4495                }, {
4496                    "guid": "bookmarkCCCC",
4497                    "title": "C",
4498                    "url": "http://example.com/c",
4499                }],
4500            }),
4501        );
4502
4503        Ok(())
4504    }
4505
4506    #[test]
4507    fn test_reconcile_sync_metadata() -> anyhow::Result<()> {
4508        let api = new_mem_api();
4509        let writer = api.open_connection(ConnectionType::ReadWrite)?;
4510
4511        let local_modified = Timestamp::from(Timestamp::now().as_millis() - 5000);
4512        let remote_modified = local_modified.as_millis() as f64 / 1000f64;
4513
4514        insert_local_json_tree(
4515            &writer,
4516            json!({
4517                "guid": &BookmarkRootGuid::Menu.as_guid(),
4518                "children": [{
4519                    // this folder is going to reconcile exactly
4520                    "guid": "folderAAAAAA",
4521                    "type": BookmarkType::Folder as u8,
4522                    "title": "A",
4523                    "date_added": local_modified,
4524                    "last_modified": local_modified,
4525                    "children": [{
4526                        "guid": "bookmarkBBBB",
4527                        "title": "B",
4528                        "url": "http://example.com/b",
4529                        "date_added": local_modified,
4530                        "last_modified": local_modified,
4531                    }]
4532                }, {
4533                    // this folder's existing child isn't on the server (so will be
4534                    // outgoing) and also will take a new child from the server.
4535                    "guid": "folderCCCCCC",
4536                    "type": BookmarkType::Folder as u8,
4537                    "title": "C",
4538                    "date_added": local_modified,
4539                    "last_modified": local_modified,
4540                    "children": [{
4541                        "guid": "bookmarkEEEE",
4542                        "title": "E",
4543                        "url": "http://example.com/e",
4544                        "date_added": local_modified,
4545                        "last_modified": local_modified,
4546                    }]
4547                }, {
4548                    // This bookmark is going to take the remote title.
4549                    "guid": "bookmarkFFFF",
4550                    "title": "f",
4551                    "url": "http://example.com/f",
4552                    "date_added": local_modified,
4553                    "last_modified": local_modified,
4554                }],
4555            }),
4556        );
4557
4558        let outgoing = apply_incoming(
4559            &api,
4560            ServerTimestamp::from_float_seconds(remote_modified),
4561            json!([{
4562                "id": "menu",
4563                "type": "folder",
4564                "parentid": "places",
4565                "parentName": "",
4566                "title": "menu",
4567                "children": ["folderAAAAAA", "folderCCCCCC", "bookmarkFFFF"],
4568                "dateAdded": local_modified.as_millis(),
4569            }, {
4570                "id": "folderAAAAAA",
4571                "type": "folder",
4572                "parentid": "menu",
4573                "parentName": "menu",
4574                "title": "A",
4575                "children": ["bookmarkBBBB"],
4576                "dateAdded": local_modified.as_millis(),
4577            }, {
4578                "id": "bookmarkBBBB",
4579                "type": "bookmark",
4580                "parentid": "folderAAAAAA",
4581                "parentName": "A",
4582                "title": "B",
4583                "bmkUri": "http://example.com/b",
4584                "dateAdded": local_modified.as_millis(),
4585            }, {
4586                "id": "folderCCCCCC",
4587                "type": "folder",
4588                "parentid": "menu",
4589                "parentName": "menu",
4590                "title": "C",
4591                "children": ["bookmarkDDDD"],
4592                "dateAdded": local_modified.as_millis(),
4593            }, {
4594                "id": "bookmarkDDDD",
4595                "type": "bookmark",
4596                "parentid": "folderCCCCCC",
4597                "parentName": "C",
4598                "title": "D",
4599                "bmkUri": "http://example.com/d",
4600                "dateAdded": local_modified.as_millis(),
4601            }, {
4602                "id": "bookmarkFFFF",
4603                "type": "bookmark",
4604                "parentid": "menu",
4605                "parentName": "menu",
4606                "title": "F",
4607                "bmkUri": "http://example.com/f",
4608                "dateAdded": local_modified.as_millis(),
4609            },]),
4610        );
4611
4612        // Assert the tree is correct even though that's not really the point
4613        // of this test.
4614        assert_local_json_tree(
4615            &writer,
4616            &BookmarkRootGuid::Menu.as_guid(),
4617            json!({
4618                "guid": &BookmarkRootGuid::Menu.as_guid(),
4619                "children": [{
4620                    // this folder is going to reconcile exactly
4621                    "guid": "folderAAAAAA",
4622                    "type": BookmarkType::Folder as u8,
4623                    "title": "A",
4624                    "children": [{
4625                        "guid": "bookmarkBBBB",
4626                        "title": "B",
4627                        "url": "http://example.com/b",
4628                    }]
4629                }, {
4630                    "guid": "folderCCCCCC",
4631                    "type": BookmarkType::Folder as u8,
4632                    "title": "C",
4633                    "children": [{
4634                        "guid": "bookmarkDDDD",
4635                        "title": "D",
4636                        "url": "http://example.com/d",
4637                    },{
4638                        "guid": "bookmarkEEEE",
4639                        "title": "E",
4640                        "url": "http://example.com/e",
4641                    }]
4642                }, {
4643                    "guid": "bookmarkFFFF",
4644                    "title": "F",
4645                    "url": "http://example.com/f",
4646                }],
4647            }),
4648        );
4649
4650        // After application everything should have SyncStatus::Normal and
4651        // a change counter of zero.
4652        for guid in &[
4653            "folderAAAAAA",
4654            "bookmarkBBBB",
4655            "folderCCCCCC",
4656            "bookmarkDDDD",
4657            "bookmarkFFFF",
4658        ] {
4659            let bm = get_raw_bookmark(&writer, &guid.into())
4660                .expect("must work")
4661                .expect("must exist");
4662            assert_eq!(bm._sync_status, SyncStatus::Normal, "{}", guid);
4663            assert_eq!(bm._sync_change_counter, 0, "{}", guid);
4664        }
4665        // And bookmarkEEEE wasn't on the server, so should be outgoing, and
4666        // it's parent too.
4667        assert!(outgoing.contains(&"bookmarkEEEE".into()));
4668        assert!(outgoing.contains(&"folderCCCCCC".into()));
4669        Ok(())
4670    }
4671
4672    /*
4673     * Due to bug 1935797, Users were running into a state where in itemsToApply
4674     * localID = None/Null, but mergedGuid was something already locally in the
4675     * tree -- this lead to an uptick of guid collision issues in `apply_remote_items`
4676     * below is an example of a 'user' going into this state and the new code fixing it
4677     */
4678    #[test]
4679    fn test_handle_unique_guid_violation() -> Result<()> {
4680        let api = new_mem_api();
4681        let db = api.get_sync_connection().unwrap();
4682        let conn = db.lock();
4683
4684        conn.execute_batch(
4685            r#"
4686            INSERT INTO moz_places(url, guid, title, frecency)
4687            VALUES
4688                ('http://example.com/', 'testPlaceGuidAAAA', 'Example site', 0)
4689            "#,
4690        )?;
4691
4692        // Insert a local row in moz_bookmarks with guid="collisionGUI"
4693        // so we already have that GUID in the table.
4694        conn.execute_batch(&format!(
4695            r#"
4696        INSERT INTO moz_bookmarks(guid, parent, fk, position, type)
4697        VALUES (
4698            'collisionGUI',
4699            (SELECT id FROM moz_bookmarks WHERE guid = '{menu}'),
4700            (SELECT id FROM moz_places WHERE guid = 'testPlaceGuidAAAA'),
4701            0,
4702            1  -- type=1 => bookmark
4703        );
4704        "#,
4705            menu = BookmarkRootGuid::Menu.as_guid(),
4706        ))?;
4707
4708        // Insert a row into itemsToApply that will cause an insert
4709        // with the same guid="collisionGUI".
4710        // localId is NULL, so the engine sees it as a "new" local item,
4711        // and remoteId could be any integer. We set newKind=1 => "bookmark."
4712        conn.execute(
4713            r#"
4714        INSERT INTO itemsToApply(
4715            mergedGuid,
4716            localId,
4717            remoteId,
4718            remoteGuid,
4719            newKind,
4720            newLevel,
4721            newTitle,
4722            newPlaceId,
4723            oldPlaceId,
4724            localDateAdded,
4725            remoteDateAdded,
4726            lastModified
4727        )
4728        VALUES (
4729            ?1,        -- mergedGuid
4730            NULL,      -- localId => so it doesn't unify
4731            999,       -- remoteId => arbitrary
4732            ?1,        -- remoteGuid
4733            1,         -- newKind=1 => bookmark
4734            0,         -- level
4735            'New Title',   -- newTitle
4736            1,             -- newPlaceId
4737            NULL,          -- oldPlaceId
4738            1000,          -- localDateAdded
4739            2000,          -- remoteDateAdded
4740            2000           -- lastModified
4741        )
4742        "#,
4743            [&"collisionGUI"],
4744        )?;
4745
4746        // Call apply_remote_items directly.
4747        // This tries "INSERT INTO moz_bookmarks(guid='collisionGUI')"
4748        // and should NOT fail with a unique constraint.
4749        let scope = conn.begin_interrupt_scope()?;
4750        apply_remote_items(&conn, &scope, Timestamp(999))?;
4751
4752        // Assert the tree still looks valid after applying
4753        assert_local_json_tree(
4754            &conn,
4755            &BookmarkRootGuid::Menu.as_guid(),
4756            json!({
4757                "guid": &BookmarkRootGuid::Menu.as_guid(),
4758                // should only be one child
4759                "children": [{
4760                    "guid": "collisionGUI",
4761                    "title": "New Title", // title was updated from remote
4762                    "url": "http://example.com/",
4763                }],
4764            }),
4765        );
4766        Ok(())
4767    }
4768}