diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
commit | 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch) | |
tree | a31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /third_party/rust/tabs/src/storage.rs | |
parent | Initial commit. (diff) | |
download | firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip |
Adding upstream version 115.8.0esr.upstream/115.8.0esr
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.rs | 727 |
1 files changed, 727 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..eaccbc3446 --- /dev/null +++ b/third_party/rust/tabs/src/storage.rs @@ -0,0 +1,727 @@ +/* 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::schema; +use crate::sync::record::TabsRecord; +use crate::DeviceType; +use rusqlite::{ + types::{FromSql, ToSql}, + 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::collections::HashMap; +use std::path::{Path, PathBuf}; +use sync15::{RemoteClient, ServerTimestamp}; +pub type TabsDeviceType = crate::DeviceType; +pub type RemoteTabRecord = RemoteTab; + +pub(crate) const TABS_CLIENT_TTL: u32 = 15_552_000; // 180 days, same as CLIENTS_TTL +const FAR_FUTURE: i64 = 4_102_405_200_000; // 2100/01/01 +const MAX_PAYLOAD_SIZE: usize = 512 * 1024; // Twice as big as desktop, still smaller than server max (2MB) +const MAX_TITLE_CHAR_LENGTH: usize = 512; // We put an upper limit on title sizes for tabs to reduce memory + +#[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 { + // The fxa_device_id of the client. *Should not* come from the id in the `clients` collection, + // because that may or may not be the fxa_device_id (currently, it will not be for desktop + // records.) + pub client_id: String, + 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::TabsMigrationLogic, + ) { + 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::TabsMigrationLogic, + )?; + 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); + } + + // We try our best to fit as many tabs in a payload as possible, this includes + // limiting the url history entries, title character count and finally drop enough tabs + // until we have small enough payload that the server will accept + pub fn prepare_local_tabs_for_upload(&self) -> Option<Vec<RemoteTab>> { + if let Some(local_tabs) = self.local_tabs.borrow().as_ref() { + let mut sanitized_tabs: Vec<RemoteTab> = 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; + // Potentially truncate the title to some limit + tab.title = slice_up_to(tab.title, MAX_TITLE_CHAR_LENGTH); + Some(tab) + }) + .collect(); + // Sort the tabs so when we trim tabs it's the oldest tabs + sanitized_tabs.sort_by(|a, b| b.last_used.cmp(&a.last_used)); + // If trimming the tab length failed for some reason, just return the untrimmed tabs + trim_tabs_length(&mut sanitized_tabs, MAX_PAYLOAD_SIZE); + return Some(sanitized_tabs); + } + None + } + + pub fn get_remote_tabs(&mut self) -> Option<Vec<ClientRemoteTabs>> { + let conn = match self.open_if_exists() { + Err(e) => { + error_support::report_error!( + "tabs-read-remote", + "Failed to read remote tabs: {}", + e + ); + return None; + } + Ok(None) => return None, + Ok(Some(conn)) => conn, + }; + + let records: Vec<(TabsRecord, ServerTimestamp)> = match conn.query_rows_and_then_cached( + "SELECT record, last_modified FROM tabs", + [], + |row| -> Result<_> { + Ok(( + serde_json::from_str(&row.get::<_, String>(0)?)?, + ServerTimestamp(row.get::<_, i64>(1)?), + )) + }, + ) { + Ok(records) => records, + Err(e) => { + error_support::report_error!("tabs-read-remote", "Failed to read database: {}", e); + return None; + } + }; + let mut crts: Vec<ClientRemoteTabs> = Vec::new(); + let remote_clients: HashMap<String, RemoteClient> = + match self.get_meta::<String>(schema::REMOTE_CLIENTS_KEY) { + Err(e) => { + error_support::report_error!( + "tabs-read-remote", + "Failed to get remote clients: {}", + e + ); + return None; + } + // We don't return early here since we still store tabs even if we don't + // "know" about the client it's associated with (incase it becomes available later) + Ok(None) => HashMap::default(), + Ok(Some(json)) => serde_json::from_str(&json).unwrap(), + }; + for (record, last_modified) in records { + let id = record.id.clone(); + let crt = if let Some(remote_client) = remote_clients.get(&id) { + ClientRemoteTabs::from_record_with_remote_client( + remote_client + .fxa_device_id + .as_ref() + .unwrap_or(&id) + .to_owned(), + last_modified, + remote_client, + record, + ) + } else { + // A record with a device that's not in our remote clients seems unlikely, but + // could happen - in most cases though, it will be due to a disconnected client - + // so we really should consider just dropping it? (Sadly though, it does seem + // possible it's actually a very recently connected client, so we keep it) + // We should get rid of this eventually - https://github.com/mozilla/application-services/issues/5199 + log::info!( + "Storing tabs from a client that doesn't appear in the devices list: {}", + id, + ); + ClientRemoteTabs::from_record(id, last_modified, record) + }; + crts.push(crt); + } + Some(crts) + } + + // Keep DB from growing infinitely since we only ask for records since our last sync + // and may or may not know about the client it's associated with -- but we could at some point + // and should start returning those tabs immediately. If that client hasn't been seen in 3 weeks, + // we remove it until it reconnects + pub fn remove_stale_clients(&mut self) -> Result<()> { + let last_sync = self.get_meta::<i64>(schema::LAST_SYNC_META_KEY)?; + if let Some(conn) = self.open_if_exists()? { + if let Some(last_sync) = last_sync { + let client_ttl_ms = (TABS_CLIENT_TTL as i64) * 1000; + // On desktop, a quick write temporarily sets the last_sync to FAR_FUTURE + // but if it doesn't set it back to the original (crash, etc) it + // means we'll most likely trash all our records (as it's more than any TTL we'd ever do) + // so we need to detect this for now until we have native quick write support + if last_sync - client_ttl_ms >= 0 && last_sync != (FAR_FUTURE * 1000) { + let tx = conn.unchecked_transaction()?; + let num_removed = tx.execute_cached( + "DELETE FROM tabs WHERE last_modified <= :last_sync - :ttl", + rusqlite::named_params! { + ":last_sync": last_sync, + ":ttl": client_ttl_ms, + }, + )?; + log::info!( + "removed {} stale clients (threshold was {})", + num_removed, + last_sync - client_ttl_ms + ); + tx.commit()?; + } + } + } + Ok(()) + } +} + +impl TabsStorage { + pub(crate) fn replace_remote_tabs( + &mut self, + // This is a tuple because we need to know what the server reports + // as the last time a record was modified + new_remote_tabs: Vec<(TabsRecord, ServerTimestamp)>, + ) -> Result<()> { + let connection = self.open_or_create()?; + let tx = connection.unchecked_transaction()?; + + // For tabs it's fine if we override the existing tabs for a remote + // there can only ever be one record for each client + for remote_tab in new_remote_tabs { + let record = remote_tab.0; + let last_modified = remote_tab.1; + log::info!( + "inserting tab for device {}, last modified at {}", + record.id, + last_modified.as_millis() + ); + tx.execute_cached( + "INSERT OR REPLACE INTO tabs (guid, record, last_modified) VALUES (:guid, :record, :last_modified);", + rusqlite::named_params! { + ":guid": &record.id, + ":record": serde_json::to_string(&record).expect("tabs don't fail to serialize"), + ":last_modified": last_modified.as_millis() + }, + )?; + } + 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); + } + + pub(crate) fn put_meta(&mut self, key: &str, value: &dyn ToSql) -> Result<()> { + let db = self.open_or_create()?; + db.execute_cached( + "REPLACE INTO moz_meta (key, value) VALUES (:key, :value)", + &[(":key", &key as &dyn ToSql), (":value", value)], + )?; + Ok(()) + } + + pub(crate) fn get_meta<T: FromSql>(&mut self, key: &str) -> Result<Option<T>> { + match self.open_if_exists() { + Ok(Some(db)) => { + let res = db.try_query_one( + "SELECT value FROM moz_meta WHERE key = :key", + &[(":key", &key)], + true, + )?; + Ok(res) + } + Err(e) => Err(e), + Ok(None) => Ok(None), + } + } + + pub(crate) fn delete_meta(&mut self, key: &str) -> Result<()> { + if let Some(db) = self.open_if_exists()? { + db.execute_cached("DELETE FROM moz_meta WHERE key = :key", &[(":key", &key)])?; + } + Ok(()) + } +} + +// Trim the amount of tabs in a list to fit the specified memory size +fn trim_tabs_length(tabs: &mut Vec<RemoteTab>, payload_size_max_bytes: usize) { + // Ported from https://searchfox.org/mozilla-central/rev/84fb1c4511312a0b9187f647d90059e3a6dd27f8/services/sync/modules/util.sys.mjs#422 + // See bug 535326 comment 8 for an explanation of the estimation + let max_serialized_size = (payload_size_max_bytes / 4) * 3 - 1500; + let size = compute_serialized_size(tabs); + if size > max_serialized_size { + // Estimate a little more than the direct fraction to maximize packing + let cutoff = (tabs.len() * max_serialized_size) / size; + tabs.truncate(cutoff); + + // Keep dropping off the last entry until the data fits. + while compute_serialized_size(tabs) > max_serialized_size { + tabs.pop(); + } + } +} + +fn compute_serialized_size(v: &Vec<RemoteTab>) -> usize { + serde_json::to_string(v).unwrap_or_default().len() +} + +// Similar to places/utils.js +// This method ensures we safely truncate a string up to a certain max_len while +// respecting char bounds to prevent rust panics. If we do end up truncating, we +// append an ellipsis to the string +pub fn slice_up_to(s: String, max_len: usize) -> String { + if max_len >= s.len() { + return s; + } + + let ellipsis = '\u{2026}'; + // Ensure we leave space for the ellipsis while still being under the max + let mut idx = max_len - ellipsis.len_utf8(); + while !s.is_char_boundary(idx) { + idx -= 1; + } + let mut new_str = s[..idx].to_string(); + new_str.push(ellipsis); + new_str +} + +// Try to keep in sync with https://searchfox.org/mozilla-central/rev/2ad13433da20a0749e1e9a10ec0ab49b987c2c8e/modules/libpref/init/all.js#3927 +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:") + || url.starts_with("data:")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::record::TabsRecordTab; + + #[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_tabs_meta() { + let dir = tempfile::tempdir().unwrap(); + let db_name = dir.path().join("test_tabs_meta.db"); + let mut db = TabsStorage::new(db_name); + let test_key = "TEST KEY A"; + let test_value = "TEST VALUE A"; + let test_key2 = "TEST KEY B"; + let test_value2 = "TEST VALUE B"; + + // should automatically make the DB if one doesn't exist + db.put_meta(test_key, &test_value).unwrap(); + db.put_meta(test_key2, &test_value2).unwrap(); + + let retrieved_value: String = db.get_meta(test_key).unwrap().expect("test value"); + let retrieved_value2: String = db.get_meta(test_key2).unwrap().expect("test value 2"); + + assert_eq!(retrieved_value, test_value); + assert_eq!(retrieved_value2, test_value2); + + // check that the value of an existing key can be updated + let test_value3 = "TEST VALUE C"; + db.put_meta(test_key, &test_value3).unwrap(); + + let retrieved_value3: String = db.get_meta(test_key).unwrap().expect("test value 3"); + + assert_eq!(retrieved_value3, test_value3); + + // check that a deleted key is not retrieved + db.delete_meta(test_key).unwrap(); + let retrieved_value4: Option<String> = db.get_meta(test_key).unwrap(); + assert!(retrieved_value4.is_none()); + } + + #[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_trimming_tab_title() { + 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: "a".repeat(MAX_TITLE_CHAR_LENGTH + 10), // Fill a string more than max + url_history: vec!["https://foo.bar".to_owned()], + icon: None, + last_used: 0, + }]); + let ellipsis_char = '\u{2026}'; + let mut truncated_title = "a".repeat(MAX_TITLE_CHAR_LENGTH - ellipsis_char.len_utf8()); + truncated_title.push(ellipsis_char); + assert_eq!( + storage.prepare_local_tabs_for_upload(), + Some(vec![ + // title trimmed to 50 characters + RemoteTab { + title: truncated_title, // title was trimmed to only max char length + url_history: vec!["https://foo.bar".to_owned()], + icon: None, + last_used: 0, + }, + ]) + ); + } + #[test] + fn test_utf8_safe_title_trim() { + 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: "😍".repeat(MAX_TITLE_CHAR_LENGTH + 10), // Fill a string more than max + url_history: vec!["https://foo.bar".to_owned()], + icon: None, + last_used: 0, + }, + RemoteTab { + title: "を".repeat(MAX_TITLE_CHAR_LENGTH + 5), // Fill a string more than max + url_history: vec!["https://foo_jp.bar".to_owned()], + icon: None, + last_used: 0, + }, + ]); + let ellipsis_char = '\u{2026}'; + // (MAX_TITLE_CHAR_LENGTH - ellipsis / "😍" bytes) + let mut truncated_title = "😍".repeat(127); + // (MAX_TITLE_CHAR_LENGTH - ellipsis / "を" bytes) + let mut truncated_jp_title = "を".repeat(169); + truncated_title.push(ellipsis_char); + truncated_jp_title.push(ellipsis_char); + let remote_tabs = storage.prepare_local_tabs_for_upload().unwrap(); + assert_eq!( + remote_tabs, + vec![ + RemoteTab { + title: truncated_title, // title was trimmed to only max char length + url_history: vec!["https://foo.bar".to_owned()], + icon: None, + last_used: 0, + }, + RemoteTab { + title: truncated_jp_title, // title was trimmed to only max char length + url_history: vec!["https://foo_jp.bar".to_owned()], + icon: None, + last_used: 0, + }, + ] + ); + // We should be less than max + assert!(remote_tabs[0].title.chars().count() <= MAX_TITLE_CHAR_LENGTH); + assert!(remote_tabs[1].title.chars().count() <= MAX_TITLE_CHAR_LENGTH); + } + #[test] + fn test_trim_tabs_length() { + let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload"); + assert_eq!(storage.prepare_local_tabs_for_upload(), None); + let mut too_many_tabs: Vec<RemoteTab> = Vec::new(); + for n in 1..5000 { + too_many_tabs.push(RemoteTab { + title: "aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa" //50 characters + .to_owned(), + url_history: vec![format!("https://foo{}.bar", n)], + icon: None, + last_used: 0, + }); + } + let tabs_mem_size = compute_serialized_size(&too_many_tabs); + // ensure we are definitely over the payload limit + assert!(tabs_mem_size > MAX_PAYLOAD_SIZE); + // Add our over-the-limit tabs to the local state + storage.update_local_state(too_many_tabs.clone()); + // prepare_local_tabs_for_upload did the trimming we needed to get under payload size + let tabs_to_upload = &storage.prepare_local_tabs_for_upload().unwrap(); + assert!(compute_serialized_size(tabs_to_upload) <= MAX_PAYLOAD_SIZE); + } + // Helper struct to model what's stored in the DB + struct TabsSQLRecord { + guid: String, + record: TabsRecord, + last_modified: i64, + } + #[test] + fn test_remove_stale_clients() { + let dir = tempfile::tempdir().unwrap(); + let db_name = dir.path().join("test_remove_stale_clients.db"); + let mut storage = TabsStorage::new(db_name); + storage.open_or_create().unwrap(); + assert!(storage.open_if_exists().unwrap().is_some()); + + let records = vec![ + TabsSQLRecord { + guid: "device-1".to_string(), + record: TabsRecord { + id: "device-1".to_string(), + client_name: "Device #1".to_string(), + tabs: vec![TabsRecordTab { + title: "the title".to_string(), + url_history: vec!["https://mozilla.org/".to_string()], + icon: Some("https://mozilla.org/icon".to_string()), + last_used: 1643764207000, + }], + }, + last_modified: 1643764207000, + }, + TabsSQLRecord { + guid: "device-outdated".to_string(), + record: TabsRecord { + id: "device-outdated".to_string(), + client_name: "Device outdated".to_string(), + tabs: vec![TabsRecordTab { + title: "the title".to_string(), + url_history: vec!["https://mozilla.org/".to_string()], + icon: Some("https://mozilla.org/icon".to_string()), + last_used: 1643764207000, + }], + }, + last_modified: 1443764207000, // old + }, + ]; + let db = storage.open_if_exists().unwrap().unwrap(); + for record in records { + db.execute( + "INSERT INTO tabs (guid, record, last_modified) VALUES (:guid, :record, :last_modified);", + rusqlite::named_params! { + ":guid": &record.guid, + ":record": serde_json::to_string(&record.record).unwrap(), + ":last_modified": &record.last_modified, + }, + ).unwrap(); + } + // pretend we just synced + let last_synced = 1643764207000_i64; + storage + .put_meta(schema::LAST_SYNC_META_KEY, &last_synced) + .unwrap(); + storage.remove_stale_clients().unwrap(); + + let remote_tabs = storage.get_remote_tabs().unwrap(); + // We should've removed the outdated device + assert_eq!(remote_tabs.len(), 1); + // Assert the correct record is still being returned + assert_eq!(remote_tabs[0].client_id, "device-1"); + } +} |