/* 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/. */ // Tabs is a bit special - it's a trivial SQL schema and is only used as a persistent // cache, and the semantics of the "tabs" collection means there's no need for // syncChangeCounter/syncStatus nor a mirror etc. use rusqlite::{Connection, Transaction}; use sql_support::open_database::{ ConnectionInitializer as MigrationLogic, Error as MigrationError, Result as MigrationResult, }; // The record is the TabsRecord struct in json and this module doesn't need to deserialize, so we just // store each client as its own row. const CREATE_TABS_TABLE_SQL: &str = " CREATE TABLE IF NOT EXISTS tabs ( guid TEXT NOT NULL PRIMARY KEY, record TEXT NOT NULL, last_modified INTEGER NOT NULL ); "; const CREATE_META_TABLE_SQL: &str = " CREATE TABLE IF NOT EXISTS moz_meta ( key TEXT PRIMARY KEY, value NOT NULL ) "; const CREATE_PENDING_REMOTE_DELETE_TABLE_SQL: &str = " CREATE TABLE IF NOT EXISTS remote_tab_commands ( id INTEGER PRIMARY KEY, device_id TEXT NOT NULL, command INTEGER NOT NULL, -- a CommandKind value url TEXT, time_requested INTEGER NOT NULL, -- local timestamp when this was initially written. time_sent INTEGER -- local timestamp, non-null == no longer pending. ); CREATE UNIQUE INDEX IF NOT EXISTS remote_tab_commands_index ON remote_tab_commands(device_id, command, url); "; pub(crate) static LAST_SYNC_META_KEY: &str = "last_sync_time"; pub(crate) static GLOBAL_SYNCID_META_KEY: &str = "global_sync_id"; pub(crate) static COLLECTION_SYNCID_META_KEY: &str = "tabs_sync_id"; // Tabs stores this in the meta table due to a unique requirement that we only know the list // of connected clients when syncing, however getting the list of tabs could be called at anytime // so we store it so we can translate from the tabs sync record ID to the FxA device id for the client pub(crate) static REMOTE_CLIENTS_KEY: &str = "remote_clients"; fn init_schema(db: &Connection) -> rusqlite::Result<()> { db.execute_batch(CREATE_TABS_TABLE_SQL)?; db.execute_batch(CREATE_META_TABLE_SQL)?; db.execute_batch(CREATE_PENDING_REMOTE_DELETE_TABLE_SQL)?; Ok(()) } pub struct TabsMigrationLogic; impl MigrationLogic for TabsMigrationLogic { const NAME: &'static str = "tabs storage db"; const END_VERSION: u32 = 5; fn prepare(&self, conn: &Connection, _db_empty: bool) -> MigrationResult<()> { let initial_pragmas = " -- We don't care about temp tables being persisted to disk. PRAGMA temp_store = 2; -- we unconditionally want write-ahead-logging mode. PRAGMA journal_mode=WAL; -- foreign keys seem worth enforcing (and again, we don't care in practice) PRAGMA foreign_keys = ON; "; conn.execute_batch(initial_pragmas)?; // This is where we'd define our sql functions if we had any! conn.set_prepared_statement_cache_capacity(128); Ok(()) } fn init(&self, db: &Transaction<'_>) -> MigrationResult<()> { error_support::debug!("Creating schemas"); init_schema(db)?; Ok(()) } fn upgrade_from(&self, db: &Transaction<'_>, version: u32) -> MigrationResult<()> { match version { 3 | 4 => upgrade_simple_commands_drop(db), 2 => upgrade_from_v2(db), 1 => upgrade_from_v1(db), _ => Err(MigrationError::IncompatibleVersion(version)), } } } // while we can get away with this, we should :) fn upgrade_simple_commands_drop(db: &Connection) -> MigrationResult<()> { // v3 changed the table schema. v5 changed the name. db.execute_batch("DROP TABLE IF EXISTS pending_remote_tab_closures;")?; db.execute_batch("DROP TABLE IF EXISTS remote_tab_commands;")?; db.execute_batch(CREATE_PENDING_REMOTE_DELETE_TABLE_SQL)?; Ok(()) } fn upgrade_from_v2(db: &Connection) -> MigrationResult<()> { db.execute_batch(CREATE_PENDING_REMOTE_DELETE_TABLE_SQL)?; Ok(()) } fn upgrade_from_v1(db: &Connection) -> MigrationResult<()> { // The previous version stored the entire payload in one row // and cleared on each sync -- it's fine to just drop it db.execute_batch("DROP TABLE tabs;")?; db.execute_batch(CREATE_TABS_TABLE_SQL)?; db.execute_batch(CREATE_META_TABLE_SQL)?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::storage::TabsStorage; use rusqlite::OptionalExtension; use serde_json::json; use sql_support::open_database::test_utils::MigratedDatabaseFile; const CREATE_V1_SCHEMA_SQL: &str = " CREATE TABLE IF NOT EXISTS tabs ( payload TEXT NOT NULL ); PRAGMA user_version=1; "; #[test] fn test_create_schema_twice() { let mut db = TabsStorage::new_with_mem_path("test"); let conn = db.open_or_create().unwrap(); init_schema(conn).expect("should allow running twice"); init_schema(conn).expect("should allow running thrice"); } #[test] fn test_tabs_db_upgrade_from_v1() { let db_file = MigratedDatabaseFile::new(TabsMigrationLogic, CREATE_V1_SCHEMA_SQL); db_file.run_all_upgrades(); // Verify we can open the DB just fine, since migration is essentially a drop // we don't need to check any data integrity let mut storage = TabsStorage::new(db_file.path); storage.open_or_create().unwrap(); assert!(storage.open_if_exists().unwrap().is_some()); let test_payload = json!({ "id": "device-with-a-tab", "clientName": "device with a tab", "tabs": [{ "title": "the title", "urlHistory": [ "https://mozilla.org/" ], "icon": "https://mozilla.org/icon", "lastUsed": 1643764207, }] }); let db = storage.open_if_exists().unwrap().unwrap(); // We should be able to insert without a SQL error after upgrade db.execute( "INSERT INTO tabs (guid, record, last_modified) VALUES (:guid, :record, :last_modified);", rusqlite::named_params! { ":guid": "my-device", ":record": serde_json::to_string(&test_payload).unwrap(), ":last_modified": "1643764207" }, ) .unwrap(); let row: Option = db .query_row("SELECT guid FROM tabs;", [], |row| row.get(0)) .optional() .unwrap(); // Verify we can query for a valid guid now assert_eq!(row.unwrap(), "my-device"); } #[test] fn test_commands_unique() { let mut db = TabsStorage::new_with_mem_path("test_commands_unique"); let conn = db.open_or_create().unwrap(); conn.execute( "INSERT INTO remote_tab_commands (device_id, command, url, time_requested, time_sent) VALUES ('d', 'close', 'url', 1, null)", [], ) .unwrap(); conn.execute( "INSERT INTO remote_tab_commands (device_id, command, url, time_requested, time_sent) VALUES ('d', 'close', 'url', 1, null)", [], ) .expect_err("identical command should fail"); } }