1use crate::db::PlacesDb;
6use crate::error::{warn, Result};
7use crate::ffi::SearchResult as FfiSearchResult;
8pub use crate::match_impl::{MatchBehavior, SearchBehavior};
9use rusqlite::Row;
10use serde_derive::*;
11use sql_support::ConnExt;
12use url::Url;
13
14fn query_flat_rows_and_then<T, F, P>(
16 conn: &PlacesDb,
17 sql: &str,
18 params: P,
19 mapper: F,
20) -> Result<Vec<T>>
21where
22 F: FnMut(&Row<'_>) -> Result<T>,
23 P: rusqlite::Params,
24{
25 let mut stmt = conn.prepare_maybe_cached(sql, true)?;
26 let iter = stmt.query_and_then(params, mapper)?;
27 Ok(iter
28 .inspect(|r| {
29 if let Err(ref e) = *r {
30 warn!("Failed to perform a search: {}", e);
31 if cfg!(debug_assertions) {
32 panic!("Failed to perform a search: {}", e);
33 }
34 }
35 })
36 .flatten()
37 .collect::<Vec<_>>())
38}
39
40#[derive(Debug, Clone)]
41pub struct SearchParams {
42 pub search_string: String,
43 pub limit: u32,
44}
45
46pub fn search_frecent(conn: &PlacesDb, params: SearchParams) -> Result<Vec<SearchResult>> {
54 let mut matches = match_with_limit(
63 conn,
64 &[
65 &OriginOrUrl::new(¶ms.search_string),
67 &Adaptive::with_behavior(
69 ¶ms.search_string,
70 MatchBehavior::Anywhere,
71 SearchBehavior::default(),
72 ),
73 &Suggestions::with_behavior(
74 ¶ms.search_string,
75 MatchBehavior::Anywhere,
76 SearchBehavior::default(),
77 ),
78 ],
79 params.limit,
80 )?;
81
82 matches.sort_unstable_by(|a, b| a.url.cmp(&b.url));
83 matches.dedup_by(|a, b| a.url == b.url);
84
85 Ok(matches)
86}
87
88pub fn match_url(conn: &PlacesDb, query: impl AsRef<str>) -> Result<Option<Url>> {
89 let scope = conn.begin_interrupt_scope()?;
90 let matcher = OriginOrUrl::new(query.as_ref());
91 let results = matcher.search(conn, 1)?;
93 scope.err_if_interrupted()?;
94 if let Some(res) = results.into_iter().next() {
97 Ok(Some(res.url))
98 } else {
99 Ok(None)
100 }
101}
102
103fn match_with_limit(
104 conn: &PlacesDb,
105 matchers: &[&dyn Matcher],
106 max_results: u32,
107) -> Result<Vec<SearchResult>> {
108 let mut results = Vec::new();
109 let mut rem_results = max_results;
110 let scope = conn.begin_interrupt_scope()?;
111 for m in matchers {
112 if rem_results == 0 {
113 break;
114 }
115 scope.err_if_interrupted()?;
116 let matches = m.search(conn, rem_results)?;
117 results.extend(matches);
118 rem_results = rem_results.saturating_sub(results.len() as u32);
119 }
120 Ok(results)
121}
122
123pub fn accept_result(conn: &PlacesDb, search_string: &str, url: &Url) -> Result<()> {
126 conn.execute(
128 "INSERT OR REPLACE INTO moz_inputhistory(place_id, input, use_count)
129 SELECT h.id, IFNULL(i.input, :input_text), IFNULL(i.use_count, 0) * .9 + 1
130 FROM moz_places h
131 LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input_text
132 WHERE url_hash = hash(:page_url) AND url = :page_url",
133 &[
134 (":input_text", &search_string),
135 (":page_url", &url.as_str()),
136 ],
137 )?;
138
139 Ok(())
140}
141
142pub fn split_after_prefix(href: &str) -> (&str, &str) {
143 let haystack = &href.as_bytes()[..href.len().min(64)];
145 match memchr::memchr(b':', haystack) {
146 None => ("", href),
147 Some(index) => {
148 let hb = href.as_bytes();
149 let mut end = index + 1;
150 if hb.len() >= end + 2 && hb[end] == b'/' && hb[end + 1] == b'/' {
151 end += 2;
152 }
153 href.split_at(end)
154 }
155 }
156}
157
158pub fn split_after_host_and_port(href: &str) -> (&str, &str) {
159 let (_, remainder) = split_after_prefix(href);
160
161 let hp_definite_end =
162 memchr::memchr3(b'/', b'?', b'#', remainder.as_bytes()).unwrap_or(remainder.len());
163
164 let (before_hp, after_hp) = remainder.split_at(hp_definite_end);
165
166 let auth_end = memchr::memchr(b'@', before_hp.as_bytes())
167 .map(|i| i + 1)
168 .unwrap_or(0);
169
170 (&before_hp[auth_end..], after_hp)
171}
172
173fn looks_like_origin(string: &str) -> bool {
174 !string.is_empty()
177 && !string.bytes().any(|c| {
178 !c.is_ascii() || c.is_ascii_whitespace() || c == b'/' || c == b'?' || c == b'#'
179 })
180}
181
182#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
183pub struct SearchResult {
184 pub search_string: String,
186
187 pub url: Url,
190
191 pub title: String,
194
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub icon_url: Option<Url>,
198
199 pub frecency: i64,
201}
202
203impl SearchResult {
204 pub fn from_adaptive_row(row: &rusqlite::Row<'_>) -> Result<Self> {
207 let search_string = row.get::<_, String>("searchString")?;
208 let _place_id = row.get::<_, i64>("id")?;
209 let url = row.get::<_, String>("url")?;
210 let history_title = row.get::<_, Option<String>>("title")?;
211 let bookmark_title = row.get::<_, Option<String>>("btitle")?;
212 let frecency = row.get::<_, i64>("frecency")?;
213 let title = bookmark_title.or(history_title).unwrap_or_default();
214 let url = Url::parse(&url)?;
215
216 Ok(Self {
217 search_string,
218 url,
219 title,
220 icon_url: None,
221 frecency,
222 })
223 }
224
225 pub fn from_suggestion_row(row: &rusqlite::Row<'_>) -> Result<Self> {
226 let search_string = row.get::<_, String>("searchString")?;
227 let url = row.get::<_, String>("url")?;
228
229 let history_title = row.get::<_, Option<String>>("title")?;
230 let bookmark_title = row.get::<_, Option<String>>("btitle")?;
231 let title = bookmark_title.or(history_title).unwrap_or_default();
232
233 let url = Url::parse(&url)?;
234
235 let frecency = row.get::<_, i64>("frecency")?;
236
237 Ok(Self {
238 search_string,
239 url,
240 title,
241 icon_url: None,
242 frecency,
243 })
244 }
245
246 pub fn from_origin_row(row: &rusqlite::Row<'_>) -> Result<Self> {
247 let search_string = row.get::<_, String>("searchString")?;
248 let url = row.get::<_, String>("url")?;
249 let display_url = row.get::<_, String>("displayURL")?;
250 let frecency = row.get::<_, i64>("frecency")?;
251
252 let url = Url::parse(&url)?;
253
254 Ok(Self {
255 search_string,
256 url,
257 title: display_url,
258 icon_url: None,
259 frecency,
260 })
261 }
262
263 pub fn from_url_row(row: &rusqlite::Row<'_>) -> Result<Self> {
264 let search_string = row.get::<_, String>("searchString")?;
265 let href = row.get::<_, String>("url")?;
266 let stripped_url = row.get::<_, String>("strippedURL")?;
267 let frecency = row.get::<_, i64>("frecency")?;
268
269 let (url, display_url) = match href.find(&stripped_url) {
270 Some(stripped_url_index) => {
271 let stripped_prefix = &href[..stripped_url_index];
272 let title = match &href[stripped_url_index + stripped_url.len()..].find('/') {
273 Some(next_slash_index) => {
274 &href[stripped_url_index
275 ..=stripped_url_index + stripped_url.len() + next_slash_index]
276 }
277 None => &href[stripped_url_index..],
278 };
279 let url = Url::parse(&[stripped_prefix, title].concat())?;
280 (url, title.into())
281 }
282 None => {
283 let url = Url::parse(&href)?;
284 (url, stripped_url)
285 }
286 };
287
288 Ok(Self {
289 search_string,
290 url,
291 title: display_url,
292 icon_url: None,
293 frecency,
294 })
295 }
296}
297
298impl From<SearchResult> for FfiSearchResult {
299 fn from(res: SearchResult) -> Self {
300 Self {
301 url: res.url,
302 title: res.title,
303 frecency: res.frecency,
304 }
305 }
306}
307
308trait Matcher {
309 fn search(&self, conn: &PlacesDb, max_results: u32) -> Result<Vec<SearchResult>>;
310}
311
312struct OriginOrUrl<'query> {
313 query: &'query str,
314}
315
316impl<'query> OriginOrUrl<'query> {
317 pub fn new(query: &'query str) -> OriginOrUrl<'query> {
318 OriginOrUrl { query }
319 }
320}
321
322const URL_SQL: &str = "
323 SELECT h.url as url,
324 :host || :remainder AS strippedURL,
325 h.frecency as frecency,
326 h.foreign_count > 0 AS bookmarked,
327 h.id as id,
328 :searchString AS searchString
329 FROM moz_places h
330 JOIN moz_origins o ON o.id = h.origin_id
331 WHERE o.rev_host = reverse_host(:host)
332 AND MAX(h.frecency, 0) >= :frecencyThreshold
333 AND h.hidden = 0
334 AND strip_prefix_and_userinfo(h.url) BETWEEN strippedURL AND strippedURL || X'FFFF'
335 UNION ALL
336 SELECT h.url as url,
337 :host || :remainder AS strippedURL,
338 h.frecency as frecency,
339 h.foreign_count > 0 AS bookmarked,
340 h.id as id,
341 :searchString AS searchString
342 FROM moz_places h
343 JOIN moz_origins o ON o.id = h.origin_id
344 WHERE o.rev_host = reverse_host(:host) || 'www.'
345 AND MAX(h.frecency, 0) >= :frecencyThreshold
346 AND h.hidden = 0
347 AND strip_prefix_and_userinfo(h.url) BETWEEN 'www.' || strippedURL AND 'www.' || strippedURL || X'FFFF'
348 ORDER BY h.frecency DESC, h.id DESC
349 LIMIT 1
350";
351const ORIGIN_SQL: &str = "
352 SELECT IFNULL(:prefix, prefix) || moz_origins.host || '/' AS url,
353 moz_origins.host || '/' AS displayURL,
354 frecency,
355 bookmarked,
356 id,
357 :searchString AS searchString
358 FROM (
359 SELECT host,
360 TOTAL(frecency) AS host_frecency,
361 (SELECT TOTAL(foreign_count) > 0 FROM moz_places
362 WHERE moz_places.origin_id = moz_origins.id) AS bookmarked
363 FROM moz_origins
364 WHERE host BETWEEN :searchString AND :searchString || X'FFFF'
365 GROUP BY host
366 HAVING host_frecency >= :frecencyThreshold
367 UNION ALL
368 SELECT host,
369 TOTAL(frecency) AS host_frecency,
370 (SELECT TOTAL(foreign_count) > 0 FROM moz_places
371 WHERE moz_places.origin_id = moz_origins.id) AS bookmarked
372 FROM moz_origins
373 WHERE host BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF'
374 GROUP BY host
375 HAVING host_frecency >= :frecencyThreshold
376 ) AS grouped_hosts
377 JOIN moz_origins ON moz_origins.host = grouped_hosts.host
378 ORDER BY frecency DESC, id DESC
379 LIMIT 1
380";
381
382impl Matcher for OriginOrUrl<'_> {
383 fn search(&self, conn: &PlacesDb, _: u32) -> Result<Vec<SearchResult>> {
384 Ok(if looks_like_origin(self.query) {
385 query_flat_rows_and_then(
386 conn,
387 ORIGIN_SQL,
388 &[
389 (":prefix", &rusqlite::types::Null as &dyn rusqlite::ToSql),
390 (":searchString", &self.query),
391 (":frecencyThreshold", &-1i64),
392 ],
393 SearchResult::from_origin_row,
394 )?
395 } else if self.query.contains(['/', ':', '?']) {
396 let (host, remainder) = split_after_host_and_port(self.query);
397 let punycode_host = idna::domain_to_ascii(host);
403 let host_str = if let Ok(host) = &punycode_host {
404 host.as_str()
405 } else {
406 return Ok(vec![]);
407 };
408 query_flat_rows_and_then(
409 conn,
410 URL_SQL,
411 &[
412 (":searchString", &self.query as &dyn rusqlite::ToSql),
413 (":host", &host_str),
414 (":remainder", &remainder),
415 (":frecencyThreshold", &-1i64),
416 ],
417 SearchResult::from_url_row,
418 )?
419 } else {
420 vec![]
421 })
422 }
423}
424
425struct Adaptive<'query> {
426 query: &'query str,
427 match_behavior: MatchBehavior,
428 search_behavior: SearchBehavior,
429}
430
431impl<'query> Adaptive<'query> {
432 pub fn with_behavior(
433 query: &'query str,
434 match_behavior: MatchBehavior,
435 search_behavior: SearchBehavior,
436 ) -> Adaptive<'query> {
437 Adaptive {
438 query,
439 match_behavior,
440 search_behavior,
441 }
442 }
443}
444
445impl Matcher for Adaptive<'_> {
446 fn search(&self, conn: &PlacesDb, max_results: u32) -> Result<Vec<SearchResult>> {
447 query_flat_rows_and_then(
448 conn,
449 "
450 SELECT h.url as url,
451 h.title as title,
452 EXISTS(SELECT 1 FROM moz_bookmarks
453 WHERE fk = h.id) AS bookmarked,
454 (SELECT title FROM moz_bookmarks
455 WHERE fk = h.id AND
456 title NOT NULL
457 ORDER BY lastModified DESC
458 LIMIT 1) AS btitle,
459 NULL AS tags,
460 h.visit_count_local + h.visit_count_remote AS visit_count,
461 h.typed as typed,
462 h.id as id,
463 NULL AS open_count,
464 h.frecency as frecency,
465 :searchString AS searchString
466 FROM (
467 SELECT ROUND(MAX(use_count) * (1 + (input = :searchString)), 1) AS rank,
468 place_id
469 FROM moz_inputhistory
470 WHERE input BETWEEN :searchString AND :searchString || X'FFFF'
471 GROUP BY place_id
472 ) AS i
473 JOIN moz_places h ON h.id = i.place_id
474 WHERE AUTOCOMPLETE_MATCH(:searchString, h.url,
475 IFNULL(btitle, h.title), tags,
476 visit_count, h.typed, bookmarked,
477 NULL, :matchBehavior, :searchBehavior)
478 ORDER BY rank DESC, h.frecency DESC
479 LIMIT :maxResults",
480 &[
481 (":searchString", &self.query as &dyn rusqlite::ToSql),
482 (":matchBehavior", &self.match_behavior),
483 (":searchBehavior", &self.search_behavior),
484 (":maxResults", &max_results),
485 ],
486 SearchResult::from_adaptive_row,
487 )
488 }
489}
490
491struct Suggestions<'query> {
492 query: &'query str,
493 match_behavior: MatchBehavior,
494 search_behavior: SearchBehavior,
495}
496
497impl<'query> Suggestions<'query> {
498 pub fn with_behavior(
499 query: &'query str,
500 match_behavior: MatchBehavior,
501 search_behavior: SearchBehavior,
502 ) -> Suggestions<'query> {
503 Suggestions {
504 query,
505 match_behavior,
506 search_behavior,
507 }
508 }
509}
510
511impl Matcher for Suggestions<'_> {
512 fn search(&self, conn: &PlacesDb, max_results: u32) -> Result<Vec<SearchResult>> {
513 query_flat_rows_and_then(
514 conn,
515 "
516 SELECT h.url, h.title,
517 EXISTS(SELECT 1 FROM moz_bookmarks
518 WHERE fk = h.id) AS bookmarked,
519 (SELECT title FROM moz_bookmarks
520 WHERE fk = h.id AND
521 title NOT NULL
522 ORDER BY lastModified DESC
523 LIMIT 1) AS btitle,
524 NULL AS tags,
525 h.visit_count_local + h.visit_count_remote AS visit_count,
526 h.typed as typed,
527 h.id as id,
528 NULL AS open_count, h.frecency, :searchString AS searchString
529 FROM moz_places h
530 WHERE h.frecency > 0
531 AND AUTOCOMPLETE_MATCH(:searchString, h.url,
532 IFNULL(btitle, h.title), tags,
533 visit_count, h.typed,
534 bookmarked, NULL,
535 :matchBehavior, :searchBehavior)
536 AND (+h.visit_count_local > 0 OR +h.visit_count_remote > 0)
537 ORDER BY h.frecency DESC, h.id DESC
538 LIMIT :maxResults",
539 &[
540 (":searchString", &self.query as &dyn rusqlite::ToSql),
541 (":matchBehavior", &self.match_behavior),
542 (":searchBehavior", &self.search_behavior),
543 (":maxResults", &max_results),
544 ],
545 SearchResult::from_suggestion_row,
546 )
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use crate::api::places_api::test::new_mem_connection;
554 use crate::observation::VisitObservation;
555 use crate::storage::history::apply_observation;
556 use crate::types::VisitType;
557 use types::Timestamp;
558
559 #[test]
560 fn split() {
561 assert_eq!(
562 split_after_prefix("http://example.com"),
563 ("http://", "example.com")
564 );
565 assert_eq!(split_after_prefix("foo:example"), ("foo:", "example"));
566 assert_eq!(split_after_prefix("foo:"), ("foo:", ""));
567 assert_eq!(split_after_prefix("notaspec"), ("", "notaspec"));
568 assert_eq!(split_after_prefix("http:/"), ("http:", "/"));
569 assert_eq!(split_after_prefix("http://"), ("http://", ""));
570
571 assert_eq!(
572 split_after_host_and_port("http://example.com/"),
573 ("example.com", "/")
574 );
575 assert_eq!(
576 split_after_host_and_port("http://example.com:8888/"),
577 ("example.com:8888", "/")
578 );
579 assert_eq!(
580 split_after_host_and_port("http://user:pass@example.com/"),
581 ("example.com", "/")
582 );
583 assert_eq!(split_after_host_and_port("foo:example"), ("example", ""));
584
585 assert_eq!(
586 split_after_host_and_port("http://foo.com/stuff/@21.3132115"),
587 ("foo.com", "/stuff/@21.3132115")
588 );
589 assert_eq!(
590 split_after_host_and_port("http://foo.com/go?email=foo@example.com"),
591 ("foo.com", "/go?email=foo@example.com")
592 );
593
594 assert_eq!(
595 split_after_host_and_port("http://a:b@foo.com/stuff/@21.3132115"),
596 ("foo.com", "/stuff/@21.3132115")
597 );
598 assert_eq!(
599 split_after_host_and_port("http://a:b@foo.com/123#abcdef@title"),
600 ("foo.com", "/123#abcdef@title")
601 );
602 }
603
604 #[test]
605 fn search() {
606 let conn = new_mem_connection();
607
608 let url = Url::parse("http://example.com/123").unwrap();
609 let visit = VisitObservation::new(url.clone())
610 .with_title("Example page 123".to_string())
611 .with_visit_type(VisitType::Typed)
612 .with_at(Timestamp::now());
613
614 apply_observation(&conn, visit).expect("Should apply visit");
615
616 let by_origin = search_frecent(
617 &conn,
618 SearchParams {
619 search_string: "example.com".into(),
620 limit: 10,
621 },
622 )
623 .expect("Should search by origin");
624 assert!(by_origin
625 .iter()
626 .any(|result| result.search_string == "example.com"
627 && result.title == "example.com/"
628 && result.url.as_str() == "http://example.com/"));
629
630 let by_url_without_path = search_frecent(
631 &conn,
632 SearchParams {
633 search_string: "http://example.com".into(),
634 limit: 10,
635 },
636 )
637 .expect("Should search by URL without path");
638 assert!(by_url_without_path
639 .iter()
640 .any(|result| result.title == "example.com/"
641 && result.url.as_str() == "http://example.com/"));
642
643 let by_url_with_path = search_frecent(
644 &conn,
645 SearchParams {
646 search_string: "http://example.com/1".into(),
647 limit: 10,
648 },
649 )
650 .expect("Should search by URL with path");
651 assert!(by_url_with_path
652 .iter()
653 .any(|result| result.title == "example.com/123"
654 && result.url.as_str() == "http://example.com/123"));
655
656 accept_result(&conn, "ample", &url).expect("Should accept input history match");
657
658 let by_adaptive = search_frecent(
659 &conn,
660 SearchParams {
661 search_string: "ample".into(),
662 limit: 10,
663 },
664 )
665 .expect("Should search by adaptive input history");
666 assert!(by_adaptive
667 .iter()
668 .any(|result| result.search_string == "ample" && result.url == url));
669
670 let with_limit = search_frecent(
671 &conn,
672 SearchParams {
673 search_string: "example".into(),
674 limit: 1,
675 },
676 )
677 .expect("Should search until reaching limit");
678 assert_eq!(
679 with_limit,
680 vec![SearchResult {
681 search_string: "example".into(),
682 url: Url::parse("http://example.com/").unwrap(),
683 title: "example.com/".into(),
684 icon_url: None,
685 frecency: 2000,
686 }]
687 );
688 }
689 #[test]
690 fn search_unicode() {
691 let conn = new_mem_connection();
692
693 let url = Url::parse("http://exämple.com/123").unwrap();
694 let visit = VisitObservation::new(url)
695 .with_title("Example page 123".to_string())
696 .with_visit_type(VisitType::Typed)
697 .with_at(Timestamp::now());
698
699 apply_observation(&conn, visit).expect("Should apply visit");
700
701 let by_url_without_path = search_frecent(
702 &conn,
703 SearchParams {
704 search_string: "http://exämple.com".into(),
705 limit: 10,
706 },
707 )
708 .expect("Should search by URL without path");
709 assert!(by_url_without_path
710 .iter()
711 .any(|result| result.title == "xn--exmple-cua.com/"
713 && result.url.as_str() == "http://xn--exmple-cua.com/"));
714
715 let by_url_with_path = search_frecent(
716 &conn,
717 SearchParams {
718 search_string: "http://exämple.com/1".into(),
719 limit: 10,
720 },
721 )
722 .expect("Should search by URL with path");
723 assert!(
724 by_url_with_path
725 .iter()
726 .any(|result| result.title == "xn--exmple-cua.com/123"
727 && result.url.as_str() == "http://xn--exmple-cua.com/123"),
728 "{:?}",
729 by_url_with_path
730 );
731
732 let ball_of_yarn_about_blank = "about:blank🧶";
735 let empty = match_url(&conn, ball_of_yarn_about_blank).unwrap();
736 assert!(empty.is_none());
737 search_frecent(
739 &conn,
740 SearchParams {
741 search_string: ball_of_yarn_about_blank.into(),
742 limit: 10,
743 },
744 )
745 .unwrap();
746 }
747 #[test]
751 #[cfg_attr(
752 debug_assertions,
753 should_panic(expected = "Failed to perform a search:")
754 )]
755 fn search_invalid_url() {
756 let conn = new_mem_connection();
757
758 conn.execute(
759 "INSERT INTO moz_places (guid, url, url_hash, frecency)
760 VALUES ('fake_guid___', 'not-a-url', hash('not-a-url'), 10)",
761 [],
762 )
763 .expect("should insert");
764 crate::storage::delete_pending_temp_tables(&conn).expect("should work");
765
766 let _ = search_frecent(
767 &conn,
768 SearchParams {
769 search_string: "not-a-url".into(),
770 limit: 10,
771 },
772 );
773 }
774}