summaryrefslogtreecommitdiffstats
path: root/third_party/rust/tabs/src/storage.rs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /third_party/rust/tabs/src/storage.rs
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'third_party/rust/tabs/src/storage.rs')
-rw-r--r--third_party/rust/tabs/src/storage.rs385
1 files changed, 385 insertions, 0 deletions
diff --git a/third_party/rust/tabs/src/storage.rs b/third_party/rust/tabs/src/storage.rs
new file mode 100644
index 0000000000..0522ed37f4
--- /dev/null
+++ b/third_party/rust/tabs/src/storage.rs
@@ -0,0 +1,385 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// From https://searchfox.org/mozilla-central/rev/ea63a0888d406fae720cf24f4727d87569a8cab5/services/sync/modules/constants.js#75
+const URI_LENGTH_MAX: usize = 65536;
+// https://searchfox.org/mozilla-central/rev/ea63a0888d406fae720cf24f4727d87569a8cab5/services/sync/modules/engines/tabs.js#8
+const TAB_ENTRIES_LIMIT: usize = 5;
+
+use crate::error::*;
+use crate::DeviceType;
+use rusqlite::{Connection, OpenFlags};
+use serde_derive::{Deserialize, Serialize};
+use sql_support::open_database::{self, open_database_with_flags};
+use sql_support::ConnExt;
+use std::cell::RefCell;
+use std::path::{Path, PathBuf};
+
+pub type TabsDeviceType = crate::DeviceType;
+pub type RemoteTabRecord = RemoteTab;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RemoteTab {
+ pub title: String,
+ pub url_history: Vec<String>,
+ pub icon: Option<String>,
+ pub last_used: i64, // In ms.
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct ClientRemoteTabs {
+ pub client_id: String, // Corresponds to the `clients` collection ID of the client.
+ pub client_name: String,
+ #[serde(
+ default = "devicetype_default_deser",
+ skip_serializing_if = "devicetype_is_unknown"
+ )]
+ pub device_type: DeviceType,
+ // serde default so we can read old rows that didn't persist this.
+ #[serde(default)]
+ pub last_modified: i64,
+ pub remote_tabs: Vec<RemoteTab>,
+}
+
+fn devicetype_default_deser() -> DeviceType {
+ // replace with `DeviceType::default_deser` once #4861 lands.
+ DeviceType::Unknown
+}
+
+// Unlike most other uses-cases, here we do allow serializing ::Unknown, but skip it.
+fn devicetype_is_unknown(val: &DeviceType) -> bool {
+ matches!(val, DeviceType::Unknown)
+}
+
+// Tabs has unique requirements for storage:
+// * The "local_tabs" exist only so we can sync them out. There's no facility to
+// query "local tabs", so there's no need to store these persistently - ie, they
+// are write-only.
+// * The "remote_tabs" exist purely for incoming items via sync - there's no facility
+// to set them locally - they are read-only.
+// Note that this means a database is only actually needed after Sync fetches remote tabs,
+// and because sync users are in the minority, the use of a database here is purely
+// optional and created on demand. The implication here is that asking for the "remote tabs"
+// when no database exists is considered a normal situation and just implies no remote tabs exist.
+// (Note however we don't attempt to remove the database when no remote tabs exist, so having
+// no remote tabs in an existing DB is also a normal situation)
+pub struct TabsStorage {
+ local_tabs: RefCell<Option<Vec<RemoteTab>>>,
+ db_path: PathBuf,
+ db_connection: Option<Connection>,
+}
+
+impl TabsStorage {
+ pub fn new(db_path: impl AsRef<Path>) -> Self {
+ Self {
+ local_tabs: RefCell::default(),
+ db_path: db_path.as_ref().to_path_buf(),
+ db_connection: None,
+ }
+ }
+
+ /// Arrange for a new memory-based TabsStorage. As per other DB semantics, creating
+ /// this isn't enough to actually create the db!
+ pub fn new_with_mem_path(db_path: &str) -> Self {
+ let name = PathBuf::from(format!("file:{}?mode=memory&cache=shared", db_path));
+ Self::new(name)
+ }
+
+ /// If a DB file exists, open and return it.
+ pub fn open_if_exists(&mut self) -> Result<Option<&Connection>> {
+ if let Some(ref existing) = self.db_connection {
+ return Ok(Some(existing));
+ }
+ let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
+ | OpenFlags::SQLITE_OPEN_URI
+ | OpenFlags::SQLITE_OPEN_READ_WRITE;
+ match open_database_with_flags(
+ self.db_path.clone(),
+ flags,
+ &crate::schema::TabsMigrationLogin,
+ ) {
+ Ok(conn) => {
+ self.db_connection = Some(conn);
+ Ok(self.db_connection.as_ref())
+ }
+ Err(open_database::Error::SqlError(rusqlite::Error::SqliteFailure(code, _)))
+ if code.code == rusqlite::ErrorCode::CannotOpen =>
+ {
+ Ok(None)
+ }
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ /// Open and return the DB, creating it if necessary.
+ pub fn open_or_create(&mut self) -> Result<&Connection> {
+ if let Some(ref existing) = self.db_connection {
+ return Ok(existing);
+ }
+ let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
+ | OpenFlags::SQLITE_OPEN_URI
+ | OpenFlags::SQLITE_OPEN_READ_WRITE
+ | OpenFlags::SQLITE_OPEN_CREATE;
+ let conn = open_database_with_flags(
+ self.db_path.clone(),
+ flags,
+ &crate::schema::TabsMigrationLogin,
+ )?;
+ self.db_connection = Some(conn);
+ Ok(self.db_connection.as_ref().unwrap())
+ }
+
+ pub fn update_local_state(&mut self, local_state: Vec<RemoteTab>) {
+ self.local_tabs.borrow_mut().replace(local_state);
+ }
+
+ pub fn prepare_local_tabs_for_upload(&self) -> Option<Vec<RemoteTab>> {
+ if let Some(local_tabs) = self.local_tabs.borrow().as_ref() {
+ return Some(
+ local_tabs
+ .iter()
+ .cloned()
+ .filter_map(|mut tab| {
+ if tab.url_history.is_empty() || !is_url_syncable(&tab.url_history[0]) {
+ return None;
+ }
+ let mut sanitized_history = Vec::with_capacity(TAB_ENTRIES_LIMIT);
+ for url in tab.url_history {
+ if sanitized_history.len() == TAB_ENTRIES_LIMIT {
+ break;
+ }
+ if is_url_syncable(&url) {
+ sanitized_history.push(url);
+ }
+ }
+ tab.url_history = sanitized_history;
+ Some(tab)
+ })
+ .collect(),
+ );
+ }
+ None
+ }
+
+ pub fn get_remote_tabs(&mut self) -> Option<Vec<ClientRemoteTabs>> {
+ match self.open_if_exists() {
+ Err(e) => {
+ error_support::report_error!(
+ "tabs-read-remote",
+ "Failed to read remote tabs: {}",
+ e
+ );
+ None
+ }
+ Ok(None) => None,
+ Ok(Some(c)) => {
+ match c.query_rows_and_then_cached(
+ "SELECT payload FROM tabs",
+ [],
+ |row| -> Result<_> { Ok(serde_json::from_str(&row.get::<_, String>(0)?)?) },
+ ) {
+ Ok(crts) => Some(crts),
+ Err(e) => {
+ error_support::report_error!(
+ "tabs-read-remote",
+ "Failed to read database: {}",
+ e
+ );
+ None
+ }
+ }
+ }
+ }
+ }
+}
+
+impl TabsStorage {
+ pub(crate) fn replace_remote_tabs(
+ &mut self,
+ new_remote_tabs: Vec<ClientRemoteTabs>,
+ ) -> Result<()> {
+ let connection = self.open_or_create()?;
+ let tx = connection.unchecked_transaction()?;
+ // delete the world - we rebuild it from scratch every sync.
+ tx.execute_batch("DELETE FROM tabs")?;
+
+ for crt in new_remote_tabs {
+ tx.execute_cached(
+ "INSERT INTO tabs (payload) VALUES (:payload);",
+ rusqlite::named_params! {
+ ":payload": serde_json::to_string(&crt).expect("tabs don't fail to serialize"),
+ },
+ )?;
+ }
+ tx.commit()?;
+ Ok(())
+ }
+
+ pub(crate) fn wipe_remote_tabs(&mut self) -> Result<()> {
+ if let Some(db) = self.open_if_exists()? {
+ db.execute_batch("DELETE FROM tabs")?;
+ }
+ Ok(())
+ }
+
+ pub(crate) fn wipe_local_tabs(&self) {
+ self.local_tabs.replace(None);
+ }
+}
+
+fn is_url_syncable(url: &str) -> bool {
+ url.len() <= URI_LENGTH_MAX
+ && !(url.starts_with("about:")
+ || url.starts_with("resource:")
+ || url.starts_with("chrome:")
+ || url.starts_with("wyciwyg:")
+ || url.starts_with("blob:")
+ || url.starts_with("file:")
+ || url.starts_with("moz-extension:"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_is_url_syncable() {
+ assert!(is_url_syncable("https://bobo.com"));
+ assert!(is_url_syncable("ftp://bobo.com"));
+ assert!(!is_url_syncable("about:blank"));
+ // XXX - this smells wrong - we should insist on a valid complete URL?
+ assert!(is_url_syncable("aboutbobo.com"));
+ assert!(!is_url_syncable("file:///Users/eoger/bobo"));
+ }
+
+ #[test]
+ fn test_open_if_exists_no_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let db_name = dir.path().join("test_open_for_read_no_file.db");
+ let mut storage = TabsStorage::new(db_name.clone());
+ assert!(storage.open_if_exists().unwrap().is_none());
+ storage.open_or_create().unwrap(); // will have created it.
+ // make a new storage, but leave the file alone.
+ let mut storage = TabsStorage::new(db_name);
+ // db file exists, so opening for read should open it.
+ assert!(storage.open_if_exists().unwrap().is_some());
+ }
+
+ #[test]
+ fn test_prepare_local_tabs_for_upload() {
+ let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload");
+ assert_eq!(storage.prepare_local_tabs_for_upload(), None);
+ storage.update_local_state(vec![
+ RemoteTab {
+ title: "".to_owned(),
+ url_history: vec!["about:blank".to_owned(), "https://foo.bar".to_owned()],
+ icon: None,
+ last_used: 0,
+ },
+ RemoteTab {
+ title: "".to_owned(),
+ url_history: vec![
+ "https://foo.bar".to_owned(),
+ "about:blank".to_owned(),
+ "about:blank".to_owned(),
+ "about:blank".to_owned(),
+ "about:blank".to_owned(),
+ "about:blank".to_owned(),
+ "about:blank".to_owned(),
+ "about:blank".to_owned(),
+ ],
+ icon: None,
+ last_used: 0,
+ },
+ RemoteTab {
+ title: "".to_owned(),
+ url_history: vec![
+ "https://foo.bar".to_owned(),
+ "about:blank".to_owned(),
+ "https://foo2.bar".to_owned(),
+ "https://foo3.bar".to_owned(),
+ "https://foo4.bar".to_owned(),
+ "https://foo5.bar".to_owned(),
+ "https://foo6.bar".to_owned(),
+ ],
+ icon: None,
+ last_used: 0,
+ },
+ RemoteTab {
+ title: "".to_owned(),
+ url_history: vec![],
+ icon: None,
+ last_used: 0,
+ },
+ ]);
+ assert_eq!(
+ storage.prepare_local_tabs_for_upload(),
+ Some(vec![
+ RemoteTab {
+ title: "".to_owned(),
+ url_history: vec!["https://foo.bar".to_owned()],
+ icon: None,
+ last_used: 0,
+ },
+ RemoteTab {
+ title: "".to_owned(),
+ url_history: vec![
+ "https://foo.bar".to_owned(),
+ "https://foo2.bar".to_owned(),
+ "https://foo3.bar".to_owned(),
+ "https://foo4.bar".to_owned(),
+ "https://foo5.bar".to_owned()
+ ],
+ icon: None,
+ last_used: 0,
+ },
+ ])
+ );
+ }
+
+ #[test]
+ fn test_old_client_remote_tabs_version() {
+ env_logger::try_init().ok();
+ // The initial version of ClientRemoteTabs which we persisted looks like:
+ let old = serde_json::json!({
+ "client_id": "id",
+ "client_name": "name",
+ "remote_tabs": [
+ serde_json::json!({
+ "title": "tab title",
+ "url_history": ["url"],
+ "last_used": 1234,
+ }),
+ ]
+ });
+
+ let dir = tempfile::tempdir().unwrap();
+ let db_name = dir.path().join("test_old_client_remote_tabs_version.db");
+ let mut storage = TabsStorage::new(db_name);
+
+ let connection = storage.open_or_create().expect("should create");
+ connection
+ .execute_cached(
+ "INSERT INTO tabs (payload) VALUES (:payload);",
+ rusqlite::named_params! {
+ ":payload": serde_json::to_string(&old).expect("tabs don't fail to serialize"),
+ },
+ )
+ .expect("should insert");
+
+ // We should be able to read it out.
+ let clients = storage.get_remote_tabs().expect("should work");
+ assert_eq!(clients.len(), 1, "must be 1 tab");
+ let client = &clients[0];
+ assert_eq!(client.client_id, "id");
+ assert_eq!(client.client_name, "name");
+ assert_eq!(client.remote_tabs.len(), 1);
+ assert_eq!(client.remote_tabs[0].title, "tab title");
+ assert_eq!(client.remote_tabs[0].url_history, vec!["url".to_string()]);
+ assert_eq!(client.remote_tabs[0].last_used, 1234);
+
+ // The old version didn't have last_modified - check it is the default.
+ assert_eq!(client.last_modified, 0);
+ }
+}