places/bookmark_sync/
incoming.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::record::{
6    BookmarkItemRecord, BookmarkRecord, BookmarkRecordId, FolderRecord, LivemarkRecord,
7    QueryRecord, SeparatorRecord,
8};
9use super::{SyncedBookmarkKind, SyncedBookmarkValidity};
10use crate::error::*;
11use crate::storage::{
12    bookmarks::maybe_truncate_title,
13    tags::{validate_tag, ValidatedTag},
14    URL_LENGTH_MAX,
15};
16use crate::types::serialize_unknown_fields;
17use rusqlite::Connection;
18use serde_json::Value as JsonValue;
19use sql_support::{self, ConnExt};
20use std::{collections::HashSet, iter};
21use sync15::bso::{IncomingBso, IncomingKind};
22use sync15::ServerTimestamp;
23use sync_guid::Guid as SyncGuid;
24use url::Url;
25
26// From Desktop's Ci.nsINavHistoryQueryOptions, but we define it as a str
27// as that's how we use it here.
28const RESULTS_AS_TAG_CONTENTS: &str = "7";
29
30/// Manages the application of incoming records into the moz_bookmarks_synced
31/// and related tables.
32pub struct IncomingApplicator<'a> {
33    db: &'a Connection,
34    // For tests to override chunk sizes so they can finish quicker!
35    default_max_variable_number: Option<usize>,
36}
37
38impl<'a> IncomingApplicator<'a> {
39    pub fn new(db: &'a Connection) -> Self {
40        Self {
41            db,
42            default_max_variable_number: None,
43        }
44    }
45
46    pub fn apply_bso(&self, record: IncomingBso) -> Result<()> {
47        let timestamp = record.envelope.modified;
48        let mut validity = SyncedBookmarkValidity::Valid;
49        let json_content = record.into_content_with_fixup::<BookmarkItemRecord>(|json| {
50            validity = fixup_bookmark_json(json)
51        });
52        match json_content.kind {
53            IncomingKind::Tombstone => {
54                self.store_incoming_tombstone(
55                    timestamp,
56                    BookmarkRecordId::from_payload_id(json_content.envelope.id.clone()).as_guid(),
57                )?;
58            }
59            IncomingKind::Content(item) => match item {
60                BookmarkItemRecord::Bookmark(b) => {
61                    self.store_incoming_bookmark(timestamp, &b, validity)?
62                }
63                BookmarkItemRecord::Query(q) => {
64                    self.store_incoming_query(timestamp, &q, validity)?
65                }
66                BookmarkItemRecord::Folder(f) => {
67                    self.store_incoming_folder(timestamp, &f, validity)?
68                }
69                BookmarkItemRecord::Livemark(l) => {
70                    self.store_incoming_livemark(timestamp, &l, validity)?
71                }
72                BookmarkItemRecord::Separator(s) => {
73                    self.store_incoming_sep(timestamp, &s, validity)?
74                }
75            },
76            IncomingKind::Malformed => {
77                trace!(
78                    "skipping malformed bookmark record: {}",
79                    json_content.envelope.id
80                );
81                error_support::report_error!(
82                    "malformed-incoming-bookmark",
83                    "Malformed bookmark record"
84                );
85            }
86        }
87        Ok(())
88    }
89
90    fn store_incoming_bookmark(
91        &self,
92        modified: ServerTimestamp,
93        b: &BookmarkRecord,
94        mut validity: SyncedBookmarkValidity,
95    ) -> Result<()> {
96        let url = match self.maybe_store_href(b.url.as_deref()) {
97            Ok(u) => Some(String::from(u)),
98            Err(e) => {
99                warn!("Incoming bookmark has an invalid URL: {:?}", e);
100                // The bookmark has an invalid URL, so we can't apply it.
101                set_replace(&mut validity);
102                None
103            }
104        };
105
106        self.db.execute_cached(
107            r#"REPLACE INTO moz_bookmarks_synced(guid, parentGuid, serverModified, needsMerge, kind,
108                                                 dateAdded, title, keyword, validity, unknownFields, placeId)
109               VALUES(:guid, :parentGuid, :serverModified, 1, :kind,
110                      :dateAdded, NULLIF(:title, ""), :keyword, :validity, :unknownFields,
111                      CASE WHEN :url ISNULL
112                      THEN NULL
113                      ELSE (SELECT id FROM moz_places
114                            WHERE url_hash = hash(:url) AND
115                            url = :url)
116                      END
117                      )"#,
118            &[
119                (
120                    ":guid",
121                    &b.record_id.as_guid().as_str() as &dyn rusqlite::ToSql,
122                ),
123                (
124                    ":parentGuid",
125                    &b.parent_record_id.as_ref().map(BookmarkRecordId::as_guid),
126                ),
127                (":serverModified", &modified.as_millis()),
128                (":kind", &SyncedBookmarkKind::Bookmark),
129                (":dateAdded", &b.date_added),
130                (":title", &maybe_truncate_title(&b.title.as_deref())),
131                (":keyword", &b.keyword),
132                (":validity", &validity),
133                (":url", &url),
134                (":unknownFields", &serialize_unknown_fields(&b.unknown_fields)?),
135            ],
136        )?;
137        for t in b.tags.iter() {
138            self.db.execute_cached(
139                "INSERT OR IGNORE INTO moz_tags(tag, lastModified)
140                 VALUES(:tag, now())",
141                &[(":tag", &t)],
142            )?;
143            self.db.execute_cached(
144                "INSERT INTO moz_bookmarks_synced_tag_relation(itemId, tagId)
145                 VALUES((SELECT id FROM moz_bookmarks_synced
146                         WHERE guid = :guid),
147                        (SELECT id FROM moz_tags
148                         WHERE tag = :tag))",
149                &[(":guid", b.record_id.as_guid().as_str()), (":tag", t)],
150            )?;
151        }
152        Ok(())
153    }
154
155    fn store_incoming_folder(
156        &self,
157        modified: ServerTimestamp,
158        f: &FolderRecord,
159        validity: SyncedBookmarkValidity,
160    ) -> Result<()> {
161        self.db.execute_cached(
162            r#"REPLACE INTO moz_bookmarks_synced(guid, parentGuid, serverModified, needsMerge, kind,
163                                                 dateAdded, validity, unknownFields, title)
164               VALUES(:guid, :parentGuid, :serverModified, 1, :kind,
165                      :dateAdded, :validity, :unknownFields, NULLIF(:title, ""))"#,
166            &[
167                (
168                    ":guid",
169                    &f.record_id.as_guid().as_str() as &dyn rusqlite::ToSql,
170                ),
171                (
172                    ":parentGuid",
173                    &f.parent_record_id.as_ref().map(BookmarkRecordId::as_guid),
174                ),
175                (":serverModified", &modified.as_millis()),
176                (":kind", &SyncedBookmarkKind::Folder),
177                (":dateAdded", &f.date_added),
178                (":title", &maybe_truncate_title(&f.title.as_deref())),
179                (":validity", &validity),
180                (
181                    ":unknownFields",
182                    &serialize_unknown_fields(&f.unknown_fields)?,
183                ),
184            ],
185        )?;
186        let default_max_variable_number = self
187            .default_max_variable_number
188            .unwrap_or_else(sql_support::default_max_variable_number);
189        sql_support::each_sized_chunk(
190            &f.children,
191            // -1 because we want to leave an extra binding parameter (`?1`)
192            // for the folder's GUID.
193            default_max_variable_number - 1,
194            |chunk, offset| -> Result<()> {
195                let sql = format!(
196                    "INSERT INTO moz_bookmarks_synced_structure(guid, parentGuid, position)
197                     VALUES {}",
198                    // Builds a fragment like `(?2, ?1, 0), (?3, ?1, 1), ...`,
199                    // where ?1 is the folder's GUID, [?2, ?3] are the first and
200                    // second child GUIDs (SQLite binding parameters index
201                    // from 1), and [0, 1] are the positions. This lets us store
202                    // the folder's children using as few statements as
203                    // possible.
204                    sql_support::repeat_display(chunk.len(), ",", |index, f| {
205                        // Each child's position is its index in `f.children`;
206                        // that is, the `offset` of the current chunk, plus the
207                        // child's `index` within the chunk.
208                        let position = offset + index;
209                        write!(f, "(?{}, ?1, {})", index + 2, position)
210                    })
211                );
212                self.db.execute(
213                    &sql,
214                    rusqlite::params_from_iter(
215                        iter::once(&f.record_id)
216                            .chain(chunk.iter())
217                            .map(|id| id.as_guid().as_str()),
218                    ),
219                )?;
220                Ok(())
221            },
222        )?;
223        Ok(())
224    }
225
226    fn store_incoming_tombstone(&self, modified: ServerTimestamp, guid: &SyncGuid) -> Result<()> {
227        self.db.execute_cached(
228            "REPLACE INTO moz_bookmarks_synced(guid, parentGuid, serverModified, needsMerge,
229                                               dateAdded, isDeleted)
230             VALUES(:guid, NULL, :serverModified, 1, 0, 1)",
231            &[
232                (":guid", guid as &dyn rusqlite::ToSql),
233                (":serverModified", &modified.as_millis()),
234            ],
235        )?;
236        Ok(())
237    }
238
239    fn maybe_rewrite_and_store_query_url(
240        &self,
241        tag_folder_name: Option<&str>,
242        record_id: &BookmarkRecordId,
243        url: Url,
244        validity: &mut SyncedBookmarkValidity,
245    ) -> Result<Option<Url>> {
246        // wow - this  is complex, but markh is struggling to see how to
247        // improve it
248        let maybe_url = {
249            // If the URL has `type={RESULTS_AS_TAG_CONTENTS}` then we
250            // rewrite the URL as `place:tag=...`
251            // Sadly we can't use `url.query_pairs()` here as the format of
252            // the url is, eg, `place:type=7` - ie, the "params" are actually
253            // the path portion of the URL.
254            let parse = url::form_urlencoded::parse(url.path().as_bytes());
255            if parse
256                .clone()
257                .any(|(k, v)| k == "type" && v == RESULTS_AS_TAG_CONTENTS)
258            {
259                if let Some(t) = tag_folder_name {
260                    validate_tag(t)
261                        .ensure_valid()
262                        .and_then(|tag| Ok(Url::parse(&format!("place:tag={}", tag))?))
263                        .map(|url| {
264                            set_reupload(validity);
265                            Some(url)
266                        })
267                        .unwrap_or_else(|_| {
268                            set_replace(validity);
269                            None
270                        })
271                } else {
272                    set_replace(validity);
273                    None
274                }
275            } else {
276                // If we have `folder=...` the folder value is a row_id
277                // from desktop, so useless to us - so we append `&excludeItems=1`
278                // if it isn't already there.
279                if parse.clone().any(|(k, _)| k == "folder") {
280                    if parse.clone().any(|(k, v)| k == "excludeItems" && v == "1") {
281                        Some(url)
282                    } else {
283                        // need to add excludeItems, and I guess we should do
284                        // it properly without resorting to string manipulation...
285                        let tail = url::form_urlencoded::Serializer::new(String::new())
286                            .extend_pairs(parse)
287                            .append_pair("excludeItems", "1")
288                            .finish();
289                        set_reupload(validity);
290                        Some(Url::parse(&format!("place:{}", tail))?)
291                    }
292                } else {
293                    // it appears to be fine!
294                    Some(url)
295                }
296            }
297        };
298        Ok(match self.maybe_store_url(maybe_url) {
299            Ok(url) => Some(url),
300            Err(e) => {
301                warn!("query {} has invalid URL: {:?}", record_id.as_guid(), e);
302                set_replace(validity);
303                None
304            }
305        })
306    }
307
308    fn store_incoming_query(
309        &self,
310        modified: ServerTimestamp,
311        q: &QueryRecord,
312        mut validity: SyncedBookmarkValidity,
313    ) -> Result<()> {
314        let url = match q.url.as_ref().and_then(|href| Url::parse(href).ok()) {
315            Some(url) => self.maybe_rewrite_and_store_query_url(
316                q.tag_folder_name.as_deref(),
317                &q.record_id,
318                url,
319                &mut validity,
320            )?,
321            None => {
322                warn!("query {} has invalid URL", &q.record_id.as_guid(),);
323                set_replace(&mut validity);
324                None
325            }
326        };
327
328        self.db.execute_cached(
329            r#"REPLACE INTO moz_bookmarks_synced(guid, parentGuid, serverModified, needsMerge, kind,
330                                                 dateAdded, title, validity, unknownFields, placeId)
331               VALUES(:guid, :parentGuid, :serverModified, 1, :kind,
332                      :dateAdded, NULLIF(:title, ""), :validity, :unknownFields,
333                      (SELECT id FROM moz_places
334                            WHERE url_hash = hash(:url) AND
335                            url = :url
336                      )
337                     )"#,
338            &[
339                (
340                    ":guid",
341                    &q.record_id.as_guid().as_str() as &dyn rusqlite::ToSql,
342                ),
343                (
344                    ":parentGuid",
345                    &q.parent_record_id.as_ref().map(BookmarkRecordId::as_guid),
346                ),
347                (":serverModified", &modified.as_millis()),
348                (":kind", &SyncedBookmarkKind::Query),
349                (":dateAdded", &q.date_added),
350                (":title", &maybe_truncate_title(&q.title.as_deref())),
351                (":validity", &validity),
352                (
353                    ":unknownFields",
354                    &serialize_unknown_fields(&q.unknown_fields)?,
355                ),
356                (":url", &url.map(String::from)),
357            ],
358        )?;
359        Ok(())
360    }
361
362    fn store_incoming_livemark(
363        &self,
364        modified: ServerTimestamp,
365        l: &LivemarkRecord,
366        mut validity: SyncedBookmarkValidity,
367    ) -> Result<()> {
368        // livemarks don't store a reference to the place, so we validate it manually.
369        fn validate_href(h: &Option<String>, guid: &SyncGuid, what: &str) -> Option<String> {
370            match h {
371                Some(h) => match Url::parse(h) {
372                    Ok(url) => {
373                        let s = url.to_string();
374                        if s.len() > URL_LENGTH_MAX {
375                            warn!("Livemark {} has a {} URL which is too long", &guid, what);
376                            None
377                        } else {
378                            Some(s)
379                        }
380                    }
381                    Err(e) => {
382                        warn!("Livemark {} has an invalid {} URL: {:?}", &guid, what, e);
383                        None
384                    }
385                },
386                None => {
387                    warn!("Livemark {} has no {} URL", &guid, what);
388                    None
389                }
390            }
391        }
392        let feed_url = validate_href(&l.feed_url, l.record_id.as_guid(), "feed");
393        let site_url = validate_href(&l.site_url, l.record_id.as_guid(), "site");
394
395        if feed_url.is_none() {
396            set_replace(&mut validity);
397        }
398
399        self.db.execute_cached(
400            "REPLACE INTO moz_bookmarks_synced(guid, parentGuid, serverModified, needsMerge, kind,
401                                               dateAdded, title, feedURL, siteURL, validity)
402             VALUES(:guid, :parentGuid, :serverModified, 1, :kind,
403                    :dateAdded, :title, :feedUrl, :siteUrl, :validity)",
404            &[
405                (
406                    ":guid",
407                    &l.record_id.as_guid().as_str() as &dyn rusqlite::ToSql,
408                ),
409                (
410                    ":parentGuid",
411                    &l.parent_record_id.as_ref().map(BookmarkRecordId::as_guid),
412                ),
413                (":serverModified", &modified.as_millis()),
414                (":kind", &SyncedBookmarkKind::Livemark),
415                (":dateAdded", &l.date_added),
416                (":title", &l.title),
417                (":feedUrl", &feed_url),
418                (":siteUrl", &site_url),
419                (":validity", &validity),
420            ],
421        )?;
422        Ok(())
423    }
424
425    fn store_incoming_sep(
426        &self,
427        modified: ServerTimestamp,
428        s: &SeparatorRecord,
429        validity: SyncedBookmarkValidity,
430    ) -> Result<()> {
431        self.db.execute_cached(
432            "REPLACE INTO moz_bookmarks_synced(guid, parentGuid, serverModified, needsMerge, kind,
433                                               dateAdded, validity, unknownFields)
434             VALUES(:guid, :parentGuid, :serverModified, 1, :kind,
435                    :dateAdded, :validity, :unknownFields)",
436            &[
437                (
438                    ":guid",
439                    &s.record_id.as_guid().as_str() as &dyn rusqlite::ToSql,
440                ),
441                (
442                    ":parentGuid",
443                    &s.parent_record_id.as_ref().map(BookmarkRecordId::as_guid),
444                ),
445                (":serverModified", &modified.as_millis()),
446                (":kind", &SyncedBookmarkKind::Separator),
447                (":dateAdded", &s.date_added),
448                (":validity", &validity),
449                (
450                    ":unknownFields",
451                    &serialize_unknown_fields(&s.unknown_fields)?,
452                ),
453            ],
454        )?;
455        Ok(())
456    }
457
458    fn maybe_store_href(&self, href: Option<&str>) -> Result<Url> {
459        if let Some(href) = href {
460            self.maybe_store_url(Some(Url::parse(href)?))
461        } else {
462            self.maybe_store_url(None)
463        }
464    }
465
466    fn maybe_store_url(&self, url: Option<Url>) -> Result<Url> {
467        if let Some(url) = url {
468            if url.as_str().len() > URL_LENGTH_MAX {
469                return Err(Error::InvalidPlaceInfo(InvalidPlaceInfo::UrlTooLong));
470            }
471            self.db.execute_cached(
472                "INSERT OR IGNORE INTO moz_places(guid, url, url_hash, frecency)
473                 VALUES(IFNULL((SELECT guid FROM moz_places
474                                WHERE url_hash = hash(:url) AND
475                                      url = :url),
476                        generate_guid()), :url, hash(:url),
477                        (CASE substr(:url, 1, 6) WHEN 'place:' THEN 0 ELSE -1 END))",
478                &[(":url", &url.as_str())],
479            )?;
480            Ok(url)
481        } else {
482            Err(Error::InvalidPlaceInfo(InvalidPlaceInfo::NoUrl))
483        }
484    }
485}
486
487/// Go through the raw JSON value and try to fixup invalid data -- this mostly means fields with
488/// invalid types.
489///
490/// This is extra important since bookmarks form a tree.  If a parent node is invalid, then we will
491/// have issues trying to merge its children.
492fn fixup_bookmark_json(json: &mut JsonValue) -> SyncedBookmarkValidity {
493    let mut validity = SyncedBookmarkValidity::Valid;
494    // the json value should always be on object, if not don't try to do any fixups.  The result will
495    // be that into_content_with_fixup() returns an IncomingContent with IncomingKind::Malformed.
496    if let JsonValue::Object(ref mut obj) = json {
497        obj.entry("parentid")
498            .and_modify(|v| fixup_optional_str(v, &mut validity));
499        obj.entry("title")
500            .and_modify(|v| fixup_optional_str(v, &mut validity));
501        obj.entry("bmkUri")
502            .and_modify(|v| fixup_optional_str(v, &mut validity));
503        obj.entry("folderName")
504            .and_modify(|v| fixup_optional_str(v, &mut validity));
505        obj.entry("feedUri")
506            .and_modify(|v| fixup_optional_str(v, &mut validity));
507        obj.entry("siteUri")
508            .and_modify(|v| fixup_optional_str(v, &mut validity));
509        obj.entry("dateAdded")
510            .and_modify(|v| fixup_optional_i64(v, &mut validity));
511        obj.entry("keyword")
512            .and_modify(|v| fixup_optional_keyword(v, &mut validity));
513        obj.entry("tags")
514            .and_modify(|v| fixup_optional_tags(v, &mut validity));
515    }
516    validity
517}
518
519fn fixup_optional_str(val: &mut JsonValue, validity: &mut SyncedBookmarkValidity) {
520    if !matches!(val, JsonValue::String(_) | JsonValue::Null) {
521        set_reupload(validity);
522        *val = JsonValue::Null;
523    }
524}
525
526fn fixup_optional_i64(val: &mut JsonValue, validity: &mut SyncedBookmarkValidity) {
527    match val {
528        // There's basically nothing to do for numbers, although we could try to drop any fraction.
529        JsonValue::Number(_) => (),
530        JsonValue::String(s) => {
531            set_reupload(validity);
532            if let Ok(n) = s.parse::<u64>() {
533                *val = JsonValue::Number(n.into());
534            } else {
535                *val = JsonValue::Null;
536            }
537        }
538        JsonValue::Null => (),
539        _ => {
540            set_reupload(validity);
541            *val = JsonValue::Null;
542        }
543    }
544}
545
546// Fixup incoming keywords by lowercasing them and removing surrounding whitespace
547//
548// Like Desktop, we don't reupload if a keyword needs to be fixed-up
549// trailing whitespace, or isn't lowercase.
550fn fixup_optional_keyword(val: &mut JsonValue, validity: &mut SyncedBookmarkValidity) {
551    match val {
552        JsonValue::String(s) => {
553            let fixed = s.trim().to_lowercase();
554            if fixed.is_empty() {
555                *val = JsonValue::Null;
556            } else if fixed != *s {
557                *val = JsonValue::String(fixed);
558            }
559        }
560        JsonValue::Null => (),
561        _ => {
562            set_reupload(validity);
563            *val = JsonValue::Null;
564        }
565    }
566}
567
568fn fixup_optional_tags(val: &mut JsonValue, validity: &mut SyncedBookmarkValidity) {
569    match val {
570        JsonValue::Array(ref tags) => {
571            let mut valid_tags = HashSet::with_capacity(tags.len());
572            for v in tags {
573                if let JsonValue::String(tag) = v {
574                    let tag = match validate_tag(tag) {
575                        ValidatedTag::Invalid(t) => {
576                            trace!("Incoming bookmark has invalid tag: {:?}", t);
577                            set_reupload(validity);
578                            continue;
579                        }
580                        ValidatedTag::Normalized(t) => {
581                            set_reupload(validity);
582                            t
583                        }
584                        ValidatedTag::Original(t) => t,
585                    };
586                    if !valid_tags.insert(tag) {
587                        trace!("Incoming bookmark has duplicate tag: {:?}", tag);
588                        set_reupload(validity);
589                    }
590                } else {
591                    trace!("Incoming bookmark has unexpected tag: {:?}", v);
592                    set_reupload(validity);
593                }
594            }
595            *val = JsonValue::Array(valid_tags.into_iter().map(JsonValue::from).collect());
596        }
597        JsonValue::Null => (),
598        _ => {
599            set_reupload(validity);
600            *val = JsonValue::Null;
601        }
602    }
603}
604
605fn set_replace(validity: &mut SyncedBookmarkValidity) {
606    if *validity < SyncedBookmarkValidity::Replace {
607        *validity = SyncedBookmarkValidity::Replace;
608    }
609}
610
611fn set_reupload(validity: &mut SyncedBookmarkValidity) {
612    if *validity < SyncedBookmarkValidity::Reupload {
613        *validity = SyncedBookmarkValidity::Reupload;
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::api::places_api::{test::new_mem_api, PlacesApi};
621    use crate::bookmark_sync::record::{BookmarkItemRecord, FolderRecord};
622    use crate::bookmark_sync::tests::SyncedBookmarkItem;
623    use crate::storage::bookmarks::BookmarkRootGuid;
624    use crate::types::UnknownFields;
625    use serde_json::{json, Value};
626
627    fn apply_incoming(api: &PlacesApi, records_json: Value) {
628        let db = api.get_sync_connection().expect("should get a db mutex");
629        let conn = db.lock();
630
631        let mut applicator = IncomingApplicator::new(&conn);
632        applicator.default_max_variable_number = Some(5);
633
634        match records_json {
635            Value::Array(records) => {
636                for record in records {
637                    applicator
638                        .apply_bso(IncomingBso::from_test_content(record))
639                        .expect("Should apply incoming and stage outgoing records");
640                }
641            }
642            Value::Object(_) => {
643                applicator
644                    .apply_bso(IncomingBso::from_test_content(records_json))
645                    .expect("Should apply incoming and stage outgoing records");
646            }
647            _ => panic!("unexpected json value"),
648        }
649    }
650
651    fn assert_incoming_creates_mirror_item(record_json: Value, expected: &SyncedBookmarkItem) {
652        let guid = record_json["id"]
653            .as_str()
654            .expect("id must be a string")
655            .to_string();
656        let api = new_mem_api();
657        apply_incoming(&api, record_json);
658        let got = SyncedBookmarkItem::get(&api.get_sync_connection().unwrap().lock(), &guid.into())
659            .expect("should work")
660            .expect("item should exist");
661        assert_eq!(*expected, got);
662    }
663
664    #[test]
665    fn test_apply_bookmark() {
666        assert_incoming_creates_mirror_item(
667            json!({
668                "id": "bookmarkAAAA",
669                "type": "bookmark",
670                "parentid": "unfiled",
671                "parentName": "unfiled",
672                "dateAdded": 1_381_542_355_843u64,
673                "title": "A",
674                "bmkUri": "http://example.com/a",
675                "tags": ["foo", "bar"],
676                "keyword": "baz",
677            }),
678            SyncedBookmarkItem::new()
679                .validity(SyncedBookmarkValidity::Valid)
680                .kind(SyncedBookmarkKind::Bookmark)
681                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
682                .title(Some("A"))
683                .url(Some("http://example.com/a"))
684                .tags(vec!["foo".into(), "bar".into()])
685                .keyword(Some("baz")),
686        );
687    }
688
689    #[test]
690    fn test_apply_folder() {
691        // apply_incoming arranges for the chunk-size to be 5, so to ensure
692        // we exercise the chunking done for folders we only need more than that.
693        let children = (1..6)
694            .map(|i| SyncGuid::from(format!("{:A>12}", i)))
695            .collect::<Vec<_>>();
696        let value = serde_json::to_value(BookmarkItemRecord::from(FolderRecord {
697            record_id: BookmarkRecordId::from_payload_id("folderAAAAAA".into()),
698            parent_record_id: Some(BookmarkRecordId::from_payload_id("unfiled".into())),
699            parent_title: Some("unfiled".into()),
700            date_added: Some(0),
701            has_dupe: true,
702            title: Some("A".into()),
703            children: children
704                .iter()
705                .map(|guid| BookmarkRecordId::from(guid.clone()))
706                .collect(),
707            unknown_fields: UnknownFields::new(),
708        }))
709        .expect("Should serialize folder with children");
710        assert_incoming_creates_mirror_item(
711            value,
712            SyncedBookmarkItem::new()
713                .validity(SyncedBookmarkValidity::Valid)
714                .kind(SyncedBookmarkKind::Folder)
715                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
716                .title(Some("A"))
717                .children(children),
718        );
719    }
720
721    #[test]
722    fn test_apply_tombstone() {
723        assert_incoming_creates_mirror_item(
724            json!({
725                "id": "deadbeef____",
726                "deleted": true
727            }),
728            SyncedBookmarkItem::new()
729                .validity(SyncedBookmarkValidity::Valid)
730                .deleted(true),
731        );
732    }
733
734    #[test]
735    fn test_apply_query() {
736        // First check that various inputs result in the expected records in
737        // the mirror table.
738
739        // A valid query (which actually looks just like a bookmark, but that's ok)
740        assert_incoming_creates_mirror_item(
741            json!({
742                "id": "query1______",
743                "type": "query",
744                "parentid": "unfiled",
745                "parentName": "Unfiled Bookmarks",
746                "dateAdded": 1_381_542_355_843u64,
747                "title": "Some query",
748                "bmkUri": "place:tag=foo",
749            }),
750            SyncedBookmarkItem::new()
751                .validity(SyncedBookmarkValidity::Valid)
752                .kind(SyncedBookmarkKind::Query)
753                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
754                .title(Some("Some query"))
755                .url(Some("place:tag=foo")),
756        );
757
758        // A query with an old "type=" param and a valid folderName. Should
759        // get Reupload due to rewriting the URL.
760        assert_incoming_creates_mirror_item(
761            json!({
762                "id": "query1______",
763                "type": "query",
764                "parentid": "unfiled",
765                "bmkUri": "place:type=7",
766                "folderName": "a-folder-name",
767            }),
768            SyncedBookmarkItem::new()
769                .validity(SyncedBookmarkValidity::Reupload)
770                .kind(SyncedBookmarkKind::Query)
771                .url(Some("place:tag=a-folder-name")),
772        );
773
774        // A query with an old "type=" param and an invalid folderName. Should
775        // get replaced with an empty URL
776        assert_incoming_creates_mirror_item(
777            json!({
778                "id": "query1______",
779                "type": "query",
780                "parentid": "unfiled",
781                "bmkUri": "place:type=7",
782                "folderName": "",
783            }),
784            SyncedBookmarkItem::new()
785                .validity(SyncedBookmarkValidity::Replace)
786                .kind(SyncedBookmarkKind::Query)
787                .url(None),
788        );
789
790        // A query with an old "folder=" but no excludeItems - should be
791        // marked as Reupload due to the URL being rewritten.
792        assert_incoming_creates_mirror_item(
793            json!({
794                "id": "query1______",
795                "type": "query",
796                "parentid": "unfiled",
797                "bmkUri": "place:folder=123",
798            }),
799            SyncedBookmarkItem::new()
800                .validity(SyncedBookmarkValidity::Reupload)
801                .kind(SyncedBookmarkKind::Query)
802                .url(Some("place:folder=123&excludeItems=1")),
803        );
804
805        // A query with an old "folder=" and already with  excludeItems -
806        // should be marked as Valid
807        assert_incoming_creates_mirror_item(
808            json!({
809                "id": "query1______",
810                "type": "query",
811                "parentid": "unfiled",
812                "bmkUri": "place:folder=123&excludeItems=1",
813            }),
814            SyncedBookmarkItem::new()
815                .validity(SyncedBookmarkValidity::Valid)
816                .kind(SyncedBookmarkKind::Query)
817                .url(Some("place:folder=123&excludeItems=1")),
818        );
819
820        // A query with a URL that can't be parsed.
821        assert_incoming_creates_mirror_item(
822            json!({
823                "id": "query1______",
824                "type": "query",
825                "parentid": "unfiled",
826                "bmkUri": "foo",
827            }),
828            SyncedBookmarkItem::new()
829                .validity(SyncedBookmarkValidity::Replace)
830                .kind(SyncedBookmarkKind::Query)
831                .url(None),
832        );
833
834        // With a missing URL
835        assert_incoming_creates_mirror_item(
836            json!({
837                "id": "query1______",
838                "type": "query",
839                "parentid": "unfiled",
840            }),
841            SyncedBookmarkItem::new()
842                .validity(SyncedBookmarkValidity::Replace)
843                .kind(SyncedBookmarkKind::Query)
844                .url(None),
845        );
846    }
847
848    #[test]
849    fn test_apply_sep() {
850        // Separators don't have much variation.
851        assert_incoming_creates_mirror_item(
852            json!({
853                "id": "sep1________",
854                "type": "separator",
855                "parentid": "unfiled",
856                "parentName": "Unfiled Bookmarks",
857            }),
858            SyncedBookmarkItem::new()
859                .validity(SyncedBookmarkValidity::Valid)
860                .kind(SyncedBookmarkKind::Separator)
861                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
862                .needs_merge(true),
863        );
864    }
865
866    #[test]
867    fn test_apply_livemark() {
868        // A livemark with missing URLs
869        assert_incoming_creates_mirror_item(
870            json!({
871                "id": "livemark1___",
872                "type": "livemark",
873                "parentid": "unfiled",
874                "parentName": "Unfiled Bookmarks",
875            }),
876            SyncedBookmarkItem::new()
877                .validity(SyncedBookmarkValidity::Replace)
878                .kind(SyncedBookmarkKind::Livemark)
879                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
880                .needs_merge(true)
881                .feed_url(None)
882                .site_url(None),
883        );
884        // Valid feed_url but invalid site_url is considered "valid", but the
885        // invalid URL is dropped.
886        assert_incoming_creates_mirror_item(
887            json!({
888                "id": "livemark1___",
889                "type": "livemark",
890                "parentid": "unfiled",
891                "parentName": "Unfiled Bookmarks",
892                "feedUri": "http://example.com",
893                "siteUri": "foo"
894            }),
895            SyncedBookmarkItem::new()
896                .validity(SyncedBookmarkValidity::Valid)
897                .kind(SyncedBookmarkKind::Livemark)
898                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
899                .needs_merge(true)
900                .feed_url(Some("http://example.com/"))
901                .site_url(None),
902        );
903        // Everything valid
904        assert_incoming_creates_mirror_item(
905            json!({
906                "id": "livemark1___",
907                "type": "livemark",
908                "parentid": "unfiled",
909                "parentName": "Unfiled Bookmarks",
910                "feedUri": "http://example.com",
911                "siteUri": "http://example.com/something"
912            }),
913            SyncedBookmarkItem::new()
914                .validity(SyncedBookmarkValidity::Valid)
915                .kind(SyncedBookmarkKind::Livemark)
916                .parent_guid(Some(&BookmarkRootGuid::Unfiled.as_guid()))
917                .needs_merge(true)
918                .feed_url(Some("http://example.com/"))
919                .site_url(Some("http://example.com/something")),
920        );
921    }
922
923    #[test]
924    fn test_apply_unknown() {
925        let api = new_mem_api();
926        let db = api.get_sync_connection().expect("should get a db mutex");
927        let conn = db.lock();
928        let applicator = IncomingApplicator::new(&conn);
929
930        let record = json!({
931            "id": "unknownAAAA",
932            "type": "fancy",
933        });
934        let inc = IncomingBso::from_test_content(record);
935        // We report an error for an invalid type but don't fail.
936        applicator
937            .apply_bso(inc)
938            .expect("Should not fail with a record with unknown type");
939    }
940}