tabs/
storage.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
5// From https://searchfox.org/mozilla-central/rev/ea63a0888d406fae720cf24f4727d87569a8cab5/services/sync/modules/constants.js#75
6const URI_LENGTH_MAX: usize = 65536;
7// https://searchfox.org/mozilla-central/rev/ea63a0888d406fae720cf24f4727d87569a8cab5/services/sync/modules/engines/tabs.js#8
8const TAB_ENTRIES_LIMIT: usize = 5;
9
10// How long we expect a remote command to live. After this time we assume it's
11// either been delivered or will not be.
12// Matches COMMAND_TTL in close_tabs.rs in fxa-client.
13const REMOTE_COMMAND_TTL_MS: u64 = 2 * 24 * 60 * 60 * 1000; // 48 hours.
14
15use crate::error::*;
16use crate::schema;
17use crate::sync::record::TabsRecord;
18use crate::DeviceType;
19use crate::{PendingCommand, RemoteCommand, Timestamp};
20use error_support::{error, info, trace, warn};
21use rusqlite::{
22    types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef},
23    Connection, OpenFlags,
24};
25use serde_derive::{Deserialize, Serialize};
26use sql_support::open_database::{self, open_database_with_flags};
27use sql_support::ConnExt;
28use std::cell::RefCell;
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use sync15::{RemoteClient, ServerTimestamp};
32pub type TabsDeviceType = crate::DeviceType;
33pub type RemoteTabRecord = RemoteTab;
34
35pub(crate) const TABS_CLIENT_TTL: u32 = 15_552_000; // 180 days, same as CLIENTS_TTL
36const FAR_FUTURE: i64 = 4_102_405_200_000; // 2100/01/01
37const MAX_PAYLOAD_SIZE: usize = 512 * 1024; // Twice as big as desktop, still smaller than server max (2MB)
38const MAX_TITLE_CHAR_LENGTH: usize = 512; // We put an upper limit on title sizes for tabs to reduce memory
39
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct RemoteTab {
42    pub title: String,
43    pub url_history: Vec<String>,
44    pub icon: Option<String>,
45    pub last_used: i64, // In ms.
46    pub inactive: bool,
47}
48
49#[derive(Clone, Debug)]
50pub struct ClientRemoteTabs {
51    // The fxa_device_id of the client. *Should not* come from the id in the `clients` collection,
52    // because that may or may not be the fxa_device_id (currently, it will not be for desktop
53    // records.)
54    pub client_id: String,
55    pub client_name: String,
56    pub device_type: DeviceType,
57    pub last_modified: i64,
58    pub remote_tabs: Vec<RemoteTab>,
59}
60
61pub(crate) enum DbConnection {
62    Created,
63    Open(Connection),
64    Closed,
65}
66
67// Tabs has unique requirements for storage:
68// * The "local_tabs" exist only so we can sync them out. There's no facility to
69//   query "local tabs", so there's no need to store these persistently - ie, they
70//   are write-only.
71// * The "remote_tabs" exist purely for incoming items via sync - there's no facility
72//   to set them locally - they are read-only.
73// Note that this means a database is only actually needed after Sync fetches remote tabs,
74// and because sync users are in the minority, the use of a database here is purely
75// optional and created on demand. The implication here is that asking for the "remote tabs"
76// when no database exists is considered a normal situation and just implies no remote tabs exist.
77// (Note however we don't attempt to remove the database when no remote tabs exist, so having
78// no remote tabs in an existing DB is also a normal situation)
79pub struct TabsStorage {
80    local_tabs: RefCell<Option<Vec<RemoteTab>>>,
81    db_path: PathBuf,
82    db_connection: DbConnection,
83}
84
85impl TabsStorage {
86    pub fn new(db_path: impl AsRef<Path>) -> Self {
87        Self {
88            local_tabs: RefCell::default(),
89            db_path: db_path.as_ref().to_path_buf(),
90            db_connection: DbConnection::Created,
91        }
92    }
93
94    pub fn close(&mut self) {
95        if let DbConnection::Open(conn) =
96            std::mem::replace(&mut self.db_connection, DbConnection::Closed)
97        {
98            if let Err(err) = conn.close() {
99                // Log the error, but continue with shutdown
100                error!("Failed to close the connection: {:?}", err);
101            }
102        }
103    }
104
105    /// Arrange for a new memory-based TabsStorage. As per other DB semantics, creating
106    /// this isn't enough to actually create the db!
107    pub fn new_with_mem_path(db_path: &str) -> Self {
108        let name = PathBuf::from(format!("file:{}?mode=memory&cache=shared", db_path));
109        Self::new(name)
110    }
111
112    /// If a DB file exists, open and return it.
113    pub fn open_if_exists(&mut self) -> Result<Option<&Connection>> {
114        match self.db_connection {
115            DbConnection::Open(ref conn) => return Ok(Some(conn)),
116            DbConnection::Closed => return Ok(None),
117            DbConnection::Created => {}
118        }
119        let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
120            | OpenFlags::SQLITE_OPEN_URI
121            | OpenFlags::SQLITE_OPEN_READ_WRITE;
122        match open_database_with_flags(
123            self.db_path.clone(),
124            flags,
125            &crate::schema::TabsMigrationLogic,
126        ) {
127            Ok(conn) => {
128                info!("tabs storage is opening an existing database");
129                self.db_connection = DbConnection::Open(conn);
130                match self.db_connection {
131                    DbConnection::Open(ref conn) => Ok(Some(conn)),
132                    _ => unreachable!("impossible value"),
133                }
134            }
135            Err(open_database::Error::SqlError(rusqlite::Error::SqliteFailure(code, _)))
136                if code.code == rusqlite::ErrorCode::CannotOpen =>
137            {
138                info!("tabs storage could not open an existing database and hasn't been asked to create one");
139                Ok(None)
140            }
141            Err(e) => Err(e.into()),
142        }
143    }
144
145    /// Open and return the DB, creating it if necessary.
146    pub fn open_or_create(&mut self) -> Result<&Connection> {
147        match self.db_connection {
148            DbConnection::Open(ref conn) => return Ok(conn),
149            DbConnection::Closed => return Err(Error::UnexpectedConnectionState),
150            DbConnection::Created => {}
151        }
152        let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
153            | OpenFlags::SQLITE_OPEN_URI
154            | OpenFlags::SQLITE_OPEN_READ_WRITE
155            | OpenFlags::SQLITE_OPEN_CREATE;
156        let conn = open_database_with_flags(
157            self.db_path.clone(),
158            flags,
159            &crate::schema::TabsMigrationLogic,
160        )?;
161        info!("tabs storage is creating a database connection");
162        self.db_connection = DbConnection::Open(conn);
163        match self.db_connection {
164            DbConnection::Open(ref conn) => Ok(conn),
165            _ => unreachable!("We just set to Open, this should be impossible."),
166        }
167    }
168
169    pub fn update_local_state(&mut self, local_state: Vec<RemoteTab>) {
170        let num_tabs = local_state.len();
171        self.local_tabs.borrow_mut().replace(local_state);
172        info!("update_local_state has {num_tabs} tab entries");
173    }
174
175    // We try our best to fit as many tabs in a payload as possible, this includes
176    // limiting the url history entries, title character count and finally drop enough tabs
177    // until we have small enough payload that the server will accept
178    pub fn prepare_local_tabs_for_upload(&self) -> Option<Vec<RemoteTab>> {
179        if let Some(local_tabs) = self.local_tabs.borrow().as_ref() {
180            let mut sanitized_tabs: Vec<RemoteTab> = local_tabs
181                .iter()
182                .cloned()
183                .filter_map(|mut tab| {
184                    if tab.url_history.is_empty() || !is_url_syncable(&tab.url_history[0]) {
185                        return None;
186                    }
187                    let mut sanitized_history = Vec::with_capacity(TAB_ENTRIES_LIMIT);
188                    for url in tab.url_history {
189                        if sanitized_history.len() == TAB_ENTRIES_LIMIT {
190                            break;
191                        }
192                        if is_url_syncable(&url) {
193                            sanitized_history.push(url);
194                        }
195                    }
196
197                    tab.url_history = sanitized_history;
198                    // Potentially truncate the title to some limit
199                    tab.title = slice_up_to(tab.title, MAX_TITLE_CHAR_LENGTH);
200                    Some(tab)
201                })
202                .collect();
203            // Sort the tabs so when we trim tabs it's the oldest tabs
204            sanitized_tabs.sort_by(|a, b| b.last_used.cmp(&a.last_used));
205            trim_tabs_length(&mut sanitized_tabs, MAX_PAYLOAD_SIZE);
206            info!(
207                "prepare_local_tabs_for_upload found {} tabs",
208                sanitized_tabs.len()
209            );
210            return Some(sanitized_tabs);
211        }
212        // It's a less than ideal outcome if at startup (or any time) we are asked to
213        // sync tabs before the app has told us what the tabs are, so make noise.
214        warn!("prepare_local_tabs_for_upload - have no local tabs");
215        None
216    }
217
218    pub fn get_remote_tabs(&mut self) -> Option<Vec<ClientRemoteTabs>> {
219        let conn = match self.open_if_exists() {
220            Err(e) => {
221                error_support::report_error!(
222                    "tabs-read-remote",
223                    "Failed to read remote tabs: {}",
224                    e
225                );
226                return None;
227            }
228            Ok(None) => return None,
229            Ok(Some(conn)) => conn,
230        };
231
232        let records: Vec<(TabsRecord, ServerTimestamp)> = match conn.query_rows_and_then_cached(
233            "SELECT record, last_modified FROM tabs",
234            [],
235            |row| -> Result<_> {
236                Ok((
237                    serde_json::from_str(&row.get::<_, String>(0)?)?,
238                    ServerTimestamp(row.get::<_, i64>(1)?),
239                ))
240            },
241        ) {
242            Ok(records) => records,
243            Err(e) => {
244                error_support::report_error!("tabs-read-remote", "Failed to read database: {}", e);
245                return None;
246            }
247        };
248        let mut crts: Vec<ClientRemoteTabs> = Vec::new();
249        let remote_clients: HashMap<String, RemoteClient> =
250            match self.get_meta::<String>(schema::REMOTE_CLIENTS_KEY) {
251                Err(e) => {
252                    error_support::report_error!(
253                        "tabs-read-remote",
254                        "Failed to get remote clients: {}",
255                        e
256                    );
257                    return None;
258                }
259                // We don't return early here since we still store tabs even if we don't
260                // "know" about the client it's associated with (incase it becomes available later)
261                Ok(None) => HashMap::default(),
262                Ok(Some(json)) => serde_json::from_str(&json).unwrap(),
263            };
264        for (record, last_modified) in records {
265            let id = record.id.clone();
266            let crt = if let Some(remote_client) = remote_clients.get(&id) {
267                ClientRemoteTabs::from_record_with_remote_client(
268                    remote_client
269                        .fxa_device_id
270                        .as_ref()
271                        .unwrap_or(&id)
272                        .to_owned(),
273                    last_modified,
274                    remote_client,
275                    record,
276                )
277            } else {
278                // A record with a device that's not in our remote clients seems unlikely, but
279                // could happen - in most cases though, it will be due to a disconnected client -
280                // so we really should consider just dropping it? (Sadly though, it does seem
281                // possible it's actually a very recently connected client, so we keep it)
282                // We should get rid of this eventually - https://github.com/mozilla/application-services/issues/5199
283                info!(
284                    "Storing tabs from a client that doesn't appear in the devices list: {}",
285                    id,
286                );
287                ClientRemoteTabs::from_record(id, last_modified, record)
288            };
289            crts.push(crt);
290        }
291        // Filter out any tabs the user requested to be closed on other devices but those devices
292        // have not yet actually closed the tab, so we hide them from the user until such time
293        // Should we add a flag here to give the call an option of not doing this?
294        let filtered_crts = self.filter_pending_remote_tabs(crts);
295        Some(filtered_crts)
296    }
297
298    fn filter_pending_remote_tabs(&mut self, crts: Vec<ClientRemoteTabs>) -> Vec<ClientRemoteTabs> {
299        let conn = match self.open_if_exists() {
300            Err(e) => {
301                error_support::report_error!(
302                    "tabs-read-remote",
303                    "Failed to read remote tabs: {}",
304                    e
305                );
306                return crts;
307            }
308            Ok(None) => return crts,
309            Ok(Some(conn)) => conn,
310        };
311        let pending_tabs_result: Result<Vec<(String, String)>> = conn.query_rows_and_then_cached(
312            "SELECT device_id, url
313             FROM remote_tab_commands
314             WHERE command = :command_close_tab",
315            rusqlite::named_params! { ":command_close_tab": CommandKind::CloseTab },
316            |row| {
317                Ok((
318                    row.get::<_, String>(0)?, // device_id
319                    row.get::<_, String>(1)?, // url
320                ))
321            },
322        );
323        // Make a hash map of all urls per client_id that we potentially want to filter
324        let pending_closures = match pending_tabs_result {
325            Ok(pending_closures) => pending_closures.into_iter().fold(
326                HashMap::new(),
327                |mut acc: HashMap<String, Vec<String>>, (device_id, url)| {
328                    acc.entry(device_id).or_default().push(url);
329                    acc
330                },
331            ),
332            Err(e) => {
333                error_support::report_error!("tabs-read-remote", "Failed to read database: {}", e);
334                return crts;
335            }
336        };
337        // Check if any of the client records that were passed in have urls that the user closed
338        // This means that they requested to close those tabs but those devices have not yet got
339        // actually closed the tabs
340        let filtered_crts: Vec<ClientRemoteTabs> = crts
341            .into_iter()
342            .map(|mut crt| {
343                crt.remote_tabs.retain(|tab| {
344                    // The top level in the url_history is the "active" tab, which we should use
345                    // TODO: probably not the best way to url check
346                    !pending_closures
347                        .get(&crt.client_id)
348                        .is_some_and(|urls| urls.contains(&tab.url_history[0]))
349                });
350                crt
351            })
352            .collect();
353        // Return the filtered crts
354        filtered_crts
355    }
356
357    // Keep DB from growing infinitely since we only ask for records since our last sync
358    // and may or may not know about the client it's associated with -- but we could at some point
359    // and should start returning those tabs immediately. If that client hasn't been seen in 3 weeks,
360    // we remove it until it reconnects
361    pub fn remove_stale_clients(&mut self) -> Result<()> {
362        let last_sync = self.get_meta::<i64>(schema::LAST_SYNC_META_KEY)?;
363        if let Some(conn) = self.open_if_exists()? {
364            if let Some(last_sync) = last_sync {
365                let client_ttl_ms = (TABS_CLIENT_TTL as i64) * 1000;
366                // On desktop, a quick write temporarily sets the last_sync to FAR_FUTURE
367                // but if it doesn't set it back to the original (crash, etc) it
368                // means we'll most likely trash all our records (as it's more than any TTL we'd ever do)
369                // so we need to detect this for now until we have native quick write support
370                if last_sync - client_ttl_ms >= 0 && last_sync != (FAR_FUTURE * 1000) {
371                    let tx = conn.unchecked_transaction()?;
372                    let num_removed = tx.execute_cached(
373                        "DELETE FROM tabs WHERE last_modified <= :last_sync - :ttl",
374                        rusqlite::named_params! {
375                            ":last_sync": last_sync,
376                            ":ttl": client_ttl_ms,
377                        },
378                    )?;
379                    info!(
380                        "removed {} stale clients (threshold was {})",
381                        num_removed,
382                        last_sync - client_ttl_ms
383                    );
384                    tx.commit()?;
385                }
386            }
387        }
388        Ok(())
389    }
390
391    pub(crate) fn replace_remote_tabs(
392        &mut self,
393        // This is a tuple because we need to know what the server reports
394        // as the last time a record was modified
395        new_remote_tabs: &Vec<(TabsRecord, ServerTimestamp)>,
396    ) -> Result<()> {
397        let connection = self.open_or_create()?;
398        let tx = connection.unchecked_transaction()?;
399
400        // For tabs it's fine if we override the existing tabs for a remote
401        // there can only ever be one record for each client
402        for remote_tab in new_remote_tabs {
403            let record = &remote_tab.0;
404            let last_modified = remote_tab.1;
405            info!(
406                "inserting tab for device {}, last modified at {}",
407                record.id,
408                last_modified.as_millis()
409            );
410            tx.execute_cached(
411                "INSERT OR REPLACE INTO tabs (guid, record, last_modified) VALUES (:guid, :record, :last_modified);",
412                rusqlite::named_params! {
413                    ":guid": &record.id,
414                    ":record": serde_json::to_string(&record).expect("tabs don't fail to serialize"),
415                    ":last_modified": last_modified.as_millis()
416                },
417            )?;
418        }
419        tx.commit()?;
420        Ok(())
421    }
422
423    pub(crate) fn wipe_remote_tabs(&mut self) -> Result<()> {
424        if let Some(db) = self.open_if_exists()? {
425            db.execute_batch("DELETE FROM tabs")?;
426        }
427        Ok(())
428    }
429
430    pub(crate) fn wipe_local_tabs(&self) {
431        self.local_tabs.replace(None);
432    }
433
434    pub(crate) fn put_meta(&mut self, key: &str, value: &dyn ToSql) -> Result<()> {
435        let db = self.open_or_create()?;
436        db.execute_cached(
437            "REPLACE INTO moz_meta (key, value) VALUES (:key, :value)",
438            &[(":key", &key as &dyn ToSql), (":value", value)],
439        )?;
440        Ok(())
441    }
442
443    pub(crate) fn get_meta<T: FromSql>(&mut self, key: &str) -> Result<Option<T>> {
444        match self.open_if_exists() {
445            Ok(Some(db)) => {
446                let res = db.try_query_one(
447                    "SELECT value FROM moz_meta WHERE key = :key",
448                    &[(":key", &key)],
449                    true,
450                )?;
451                Ok(res)
452            }
453            Err(e) => Err(e),
454            Ok(None) => Ok(None),
455        }
456    }
457
458    pub(crate) fn delete_meta(&mut self, key: &str) -> Result<()> {
459        if let Some(db) = self.open_if_exists()? {
460            db.execute_cached("DELETE FROM moz_meta WHERE key = :key", &[(":key", &key)])?;
461        }
462        Ok(())
463    }
464}
465
466// Implementations related to storage of remotely closing remote tabs.
467// We should probably split this module!
468impl TabsStorage {
469    /// Store tabs that we requested to close on other devices but
470    /// not yet executed on target device, other calls like getAll()
471    /// will check against this table to filter out any urls
472    pub fn add_remote_tab_command(
473        &mut self,
474        device_id: &str,
475        command: &RemoteCommand,
476    ) -> Result<bool> {
477        self.add_remote_tab_command_at(device_id, command, Timestamp::now())
478    }
479
480    pub fn add_remote_tab_command_at(
481        &mut self,
482        device_id: &str,
483        command: &RemoteCommand,
484        time_requested: Timestamp,
485    ) -> Result<bool> {
486        let connection = self.open_or_create()?;
487        let RemoteCommand::CloseTab { url } = command;
488        info!("Adding remote command for {device_id} at {time_requested}");
489        trace!("command is {command:?}");
490        // tx maybe not needed for single write?
491        let tx = connection.unchecked_transaction()?;
492        let changes = tx.execute_cached(
493            "INSERT OR IGNORE INTO remote_tab_commands
494                (device_id, command, url, time_requested, time_sent)
495            VALUES (:device_id, :command, :url, :time_requested, null)",
496            rusqlite::named_params! {
497                ":device_id": &device_id,
498                ":url": url,
499                ":time_requested": time_requested,
500                ":command": command.as_ref(),
501            },
502        )?;
503        tx.commit()?;
504        Ok(changes != 0)
505    }
506
507    pub fn remove_remote_tab_command(
508        &mut self,
509        device_id: &str,
510        command: &RemoteCommand,
511    ) -> Result<bool> {
512        let connection = self.open_or_create()?;
513        let RemoteCommand::CloseTab { url } = command;
514        info!("removing remote tab close details: client={device_id}");
515        let tx = connection.unchecked_transaction()?;
516        let changes = tx.execute_cached(
517            "DELETE FROM remote_tab_commands
518             WHERE device_id = :device_id AND command = :command AND url = :url;",
519            rusqlite::named_params! {
520                ":device_id": &device_id,
521                ":url": url,
522                ":command": command.as_ref(),
523            },
524        )?;
525        tx.commit()?;
526        Ok(changes != 0)
527    }
528
529    pub fn get_unsent_commands(&mut self) -> Result<Vec<PendingCommand>> {
530        self.do_get_pending_commands("WHERE time_sent IS NULL")
531    }
532
533    fn do_get_pending_commands(&mut self, where_clause: &str) -> Result<Vec<PendingCommand>> {
534        let Some(conn) = self.open_if_exists()? else {
535            return Ok(Vec::new());
536        };
537        let result = conn.query_rows_and_then_cached(
538            &format!(
539                "SELECT device_id, command, url, time_requested, time_sent
540                    FROM remote_tab_commands
541                    {where_clause}
542                    ORDER BY time_requested
543                    LIMIT 1000 -- sue me!"
544            ),
545            [],
546            |row| -> Result<_> {
547                // overly cautious I guess - ignore bad enum values rather than failing
548                let command = match row.get::<_, CommandKind>(1) {
549                    Ok(c) => c,
550                    Err(e) => {
551                        error!("do_get_pending_commands: ignoring error fetching command: {e:?}");
552                        return Ok(None);
553                    }
554                };
555                Ok(Some(match command {
556                    CommandKind::CloseTab => PendingCommand {
557                        device_id: row.get::<_, String>(0)?,
558                        command: RemoteCommand::CloseTab {
559                            url: row.get::<_, String>(2)?,
560                        },
561                        time_requested: row.get::<_, Timestamp>(3)?,
562                        time_sent: row.get::<_, Option<Timestamp>>(4)?,
563                    },
564                }))
565            },
566        );
567        Ok(match result {
568            Ok(records) => records.into_iter().flatten().collect(),
569            Err(e) => {
570                error_support::report_error!("tabs-get_unsent", "Failed to read database: {}", e);
571                Vec::new()
572            }
573        })
574    }
575
576    pub fn set_pending_command_sent(&mut self, command: &PendingCommand) -> Result<bool> {
577        let connection = self.open_or_create()?;
578        let RemoteCommand::CloseTab { url } = &command.command;
579        info!("setting remote tab sent: client={}", command.device_id);
580        trace!("command: {command:?}");
581        let tx = connection.unchecked_transaction()?;
582        let ts = Timestamp::now();
583        let changes = tx.execute_cached(
584            "UPDATE remote_tab_commands
585             SET time_sent = :ts
586             WHERE device_id = :device_id AND command = :command AND url = :url;",
587            rusqlite::named_params! {
588                ":command": command.command.as_ref(),
589                ":device_id": &command.device_id,
590                ":url": url,
591                ":ts": &ts,
592            },
593        )?;
594        tx.commit()?;
595        Ok(changes != 0)
596    }
597
598    // Remove any pending tabs that are 24hrs older than the last time that client has synced
599    // Or that client's incoming tabs does not have those tabs anymore
600    pub fn remove_old_pending_closures(
601        &mut self,
602        // This is a tuple because we need to know what the server reports
603        // as the last time a record was modified
604        new_remote_tabs: &[(TabsRecord, ServerTimestamp)],
605    ) -> Result<()> {
606        // we need to load our map of client-id -> RemoteClient so we can use the
607        // fxa device ID and not the sync client id.
608        let remote_clients: HashMap<String, RemoteClient> = {
609            match self.get_meta::<String>(schema::REMOTE_CLIENTS_KEY)? {
610                None => HashMap::default(),
611                Some(json) => serde_json::from_str(&json).unwrap(),
612            }
613        };
614
615        let conn = self.open_or_create()?;
616        let tx = conn.unchecked_transaction()?;
617
618        // Insert new remote tabs into a temporary table
619        conn.execute(
620            "CREATE TEMP TABLE if not exists new_remote_tabs (device_id TEXT, url TEXT)",
621            [],
622        )?;
623        conn.execute("DELETE FROM new_remote_tabs", [])?; // Clear previous entries
624
625        for (record, _) in new_remote_tabs.iter() {
626            let fxa_id = remote_clients
627                .get(&record.id)
628                .and_then(|r| r.fxa_device_id.as_ref())
629                .unwrap_or(&record.id);
630            for tab in &record.tabs {
631                if let Some(url) = tab.url_history.first() {
632                    conn.execute(
633                        "INSERT INTO new_remote_tabs (device_id, url) VALUES (?, ?)",
634                        rusqlite::params![fxa_id, url],
635                    )?;
636                }
637            }
638        }
639
640        // Delete entries from pending closures that do not exist in the new remote tabs
641        let delete_sql = "
642         DELETE FROM remote_tab_commands
643         WHERE
644            (device_id IN (SELECT device_id from new_remote_tabs))
645         AND
646         (
647            url NOT IN (
648            SELECT url from new_remote_tabs
649            WHERE new_remote_tabs.device_id = device_id
650            AND :command_close_tab = remote_tab_commands.command)
651         )";
652        conn.execute(
653            delete_sql,
654            rusqlite::named_params! {
655                ":command_close_tab": CommandKind::CloseTab,
656            },
657        )?;
658
659        info!(
660            "deleted {} pending tab closures because they were not in the new tabs",
661            conn.changes()
662        );
663
664        // Anything that couldn't be removed above and is older than REMOTE_COMMAND_TTL_MS
665        // is assumed not closeable and we can remove it from the list
666        let sql = format!("
667            DELETE FROM remote_tab_commands
668            WHERE device_id IN (
669                SELECT guid FROM tabs
670            ) AND (SELECT last_modified FROM tabs WHERE guid = device_id) - time_requested >= {REMOTE_COMMAND_TTL_MS}
671        ");
672        tx.execute_cached(&sql, [])?;
673        info!("deleted {} records because they timed out", conn.changes());
674
675        // Commit changes and clean up temp
676        tx.commit()?;
677        conn.execute("DROP TABLE new_remote_tabs", [])?;
678        Ok(())
679    }
680}
681
682// Simple enum for the DB.
683#[derive(Debug, Copy, Clone)]
684#[repr(u8)]
685enum CommandKind {
686    CloseTab = 0,
687}
688
689impl AsRef<CommandKind> for RemoteCommand {
690    // Required method
691    fn as_ref(&self) -> &CommandKind {
692        match self {
693            RemoteCommand::CloseTab { .. } => &CommandKind::CloseTab,
694        }
695    }
696}
697
698impl FromSql for CommandKind {
699    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
700        Ok(match value.as_i64()? {
701            0 => CommandKind::CloseTab,
702            _ => return Err(FromSqlError::InvalidType),
703        })
704    }
705}
706
707impl ToSql for CommandKind {
708    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
709        Ok(ToSqlOutput::from(*self as u8))
710    }
711}
712
713/// Trim the amount of tabs in a list to fit the specified memory size.
714/// If trimming the tab length fails for some reason, just return the untrimmed tabs.
715fn trim_tabs_length(tabs: &mut Vec<RemoteTab>, payload_size_max_bytes: usize) {
716    if let Some(count) = payload_support::try_fit_items(tabs, payload_size_max_bytes).as_some() {
717        tabs.truncate(count.get());
718    }
719}
720
721// Similar to places/utils.js
722// This method ensures we safely truncate a string up to a certain max_len while
723// respecting char bounds to prevent rust panics. If we do end up truncating, we
724// append an ellipsis to the string
725pub fn slice_up_to(s: String, max_len: usize) -> String {
726    if max_len >= s.len() {
727        return s;
728    }
729
730    let ellipsis = '\u{2026}';
731    // Ensure we leave space for the ellipsis while still being under the max
732    let mut idx = max_len - ellipsis.len_utf8();
733    while !s.is_char_boundary(idx) {
734        idx -= 1;
735    }
736    let mut new_str = s[..idx].to_string();
737    new_str.push(ellipsis);
738    new_str
739}
740
741// Try to keep in sync with https://searchfox.org/mozilla-central/rev/2ad13433da20a0749e1e9a10ec0ab49b987c2c8e/modules/libpref/init/all.js#3927
742fn is_url_syncable(url: &str) -> bool {
743    url.len() <= URI_LENGTH_MAX
744        && !(url.starts_with("about:")
745            || url.starts_with("resource:")
746            || url.starts_with("chrome:")
747            || url.starts_with("wyciwyg:")
748            || url.starts_with("blob:")
749            || url.starts_with("file:")
750            || url.starts_with("moz-extension:")
751            || url.starts_with("data:"))
752}
753
754#[cfg(test)]
755mod tests {
756    use payload_support::compute_serialized_size;
757    use std::time::Duration;
758
759    use super::*;
760    use crate::{sync::record::TabsRecordTab, PendingCommand};
761
762    impl RemoteCommand {
763        fn close_tab(url: &str) -> Self {
764            RemoteCommand::CloseTab {
765                url: url.to_string(),
766            }
767        }
768    }
769
770    #[test]
771    fn test_is_url_syncable() {
772        assert!(is_url_syncable("https://bobo.com"));
773        assert!(is_url_syncable("ftp://bobo.com"));
774        assert!(!is_url_syncable("about:blank"));
775        // XXX - this smells wrong - we should insist on a valid complete URL?
776        assert!(is_url_syncable("aboutbobo.com"));
777        assert!(!is_url_syncable("file:///Users/eoger/bobo"));
778    }
779
780    #[test]
781    fn test_open_if_exists_no_file() {
782        error_support::init_for_tests();
783        let dir = tempfile::tempdir().unwrap();
784        let db_name = dir.path().join("test_open_for_read_no_file.db");
785        let mut storage = TabsStorage::new(db_name.clone());
786        assert!(storage.open_if_exists().unwrap().is_none());
787        storage.open_or_create().unwrap(); // will have created it.
788                                           // make a new storage, but leave the file alone.
789        let mut storage = TabsStorage::new(db_name);
790        // db file exists, so opening for read should open it.
791        assert!(storage.open_if_exists().unwrap().is_some());
792    }
793
794    #[test]
795    fn test_tabs_meta() {
796        error_support::init_for_tests();
797        let dir = tempfile::tempdir().unwrap();
798        let db_name = dir.path().join("test_tabs_meta.db");
799        let mut db = TabsStorage::new(db_name);
800        let test_key = "TEST KEY A";
801        let test_value = "TEST VALUE A";
802        let test_key2 = "TEST KEY B";
803        let test_value2 = "TEST VALUE B";
804
805        // should automatically make the DB if one doesn't exist
806        db.put_meta(test_key, &test_value).unwrap();
807        db.put_meta(test_key2, &test_value2).unwrap();
808
809        let retrieved_value: String = db.get_meta(test_key).unwrap().expect("test value");
810        let retrieved_value2: String = db.get_meta(test_key2).unwrap().expect("test value 2");
811
812        assert_eq!(retrieved_value, test_value);
813        assert_eq!(retrieved_value2, test_value2);
814
815        // check that the value of an existing key can be updated
816        let test_value3 = "TEST VALUE C";
817        db.put_meta(test_key, &test_value3).unwrap();
818
819        let retrieved_value3: String = db.get_meta(test_key).unwrap().expect("test value 3");
820
821        assert_eq!(retrieved_value3, test_value3);
822
823        // check that a deleted key is not retrieved
824        db.delete_meta(test_key).unwrap();
825        let retrieved_value4: Option<String> = db.get_meta(test_key).unwrap();
826        assert!(retrieved_value4.is_none());
827    }
828
829    #[test]
830    fn test_prepare_local_tabs_for_upload() {
831        error_support::init_for_tests();
832        let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload");
833        assert_eq!(storage.prepare_local_tabs_for_upload(), None);
834        storage.update_local_state(vec![
835            RemoteTab {
836                url_history: vec!["about:blank".to_owned(), "https://foo.bar".to_owned()],
837                ..Default::default()
838            },
839            RemoteTab {
840                url_history: vec![
841                    "https://foo.bar".to_owned(),
842                    "about:blank".to_owned(),
843                    "about:blank".to_owned(),
844                    "about:blank".to_owned(),
845                    "about:blank".to_owned(),
846                    "about:blank".to_owned(),
847                    "about:blank".to_owned(),
848                    "about:blank".to_owned(),
849                ],
850                ..Default::default()
851            },
852            RemoteTab {
853                url_history: vec![
854                    "https://foo.bar".to_owned(),
855                    "about:blank".to_owned(),
856                    "https://foo2.bar".to_owned(),
857                    "https://foo3.bar".to_owned(),
858                    "https://foo4.bar".to_owned(),
859                    "https://foo5.bar".to_owned(),
860                    "https://foo6.bar".to_owned(),
861                ],
862                ..Default::default()
863            },
864            RemoteTab {
865                ..Default::default()
866            },
867        ]);
868        assert_eq!(
869            storage.prepare_local_tabs_for_upload(),
870            Some(vec![
871                RemoteTab {
872                    url_history: vec!["https://foo.bar".to_owned()],
873                    ..Default::default()
874                },
875                RemoteTab {
876                    url_history: vec![
877                        "https://foo.bar".to_owned(),
878                        "https://foo2.bar".to_owned(),
879                        "https://foo3.bar".to_owned(),
880                        "https://foo4.bar".to_owned(),
881                        "https://foo5.bar".to_owned()
882                    ],
883                    ..Default::default()
884                },
885            ])
886        );
887    }
888    #[test]
889    fn test_trimming_tab_title() {
890        error_support::init_for_tests();
891        let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload");
892        assert_eq!(storage.prepare_local_tabs_for_upload(), None);
893        storage.update_local_state(vec![RemoteTab {
894            title: "a".repeat(MAX_TITLE_CHAR_LENGTH + 10), // Fill a string more than max
895            url_history: vec!["https://foo.bar".to_owned()],
896            ..Default::default()
897        }]);
898        let ellipsis_char = '\u{2026}';
899        let mut truncated_title = "a".repeat(MAX_TITLE_CHAR_LENGTH - ellipsis_char.len_utf8());
900        truncated_title.push(ellipsis_char);
901        assert_eq!(
902            storage.prepare_local_tabs_for_upload(),
903            Some(vec![
904                // title trimmed to 50 characters
905                RemoteTab {
906                    title: truncated_title, // title was trimmed to only max char length
907                    url_history: vec!["https://foo.bar".to_owned()],
908                    ..Default::default()
909                },
910            ])
911        );
912    }
913    #[test]
914    fn test_utf8_safe_title_trim() {
915        error_support::init_for_tests();
916        let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload");
917        assert_eq!(storage.prepare_local_tabs_for_upload(), None);
918        storage.update_local_state(vec![
919            RemoteTab {
920                title: "😍".repeat(MAX_TITLE_CHAR_LENGTH + 10), // Fill a string more than max
921                url_history: vec!["https://foo.bar".to_owned()],
922                ..Default::default()
923            },
924            RemoteTab {
925                title: "を".repeat(MAX_TITLE_CHAR_LENGTH + 5), // Fill a string more than max
926                url_history: vec!["https://foo_jp.bar".to_owned()],
927                ..Default::default()
928            },
929        ]);
930        let ellipsis_char = '\u{2026}';
931        // (MAX_TITLE_CHAR_LENGTH - ellipsis / "😍" bytes)
932        let mut truncated_title = "😍".repeat(127);
933        // (MAX_TITLE_CHAR_LENGTH - ellipsis / "を" bytes)
934        let mut truncated_jp_title = "を".repeat(169);
935        truncated_title.push(ellipsis_char);
936        truncated_jp_title.push(ellipsis_char);
937        let remote_tabs = storage.prepare_local_tabs_for_upload().unwrap();
938        assert_eq!(
939            remote_tabs,
940            vec![
941                RemoteTab {
942                    title: truncated_title, // title was trimmed to only max char length
943                    url_history: vec!["https://foo.bar".to_owned()],
944                    ..Default::default()
945                },
946                RemoteTab {
947                    title: truncated_jp_title, // title was trimmed to only max char length
948                    url_history: vec!["https://foo_jp.bar".to_owned()],
949                    ..Default::default()
950                },
951            ]
952        );
953        // We should be less than max
954        assert!(remote_tabs[0].title.chars().count() <= MAX_TITLE_CHAR_LENGTH);
955        assert!(remote_tabs[1].title.chars().count() <= MAX_TITLE_CHAR_LENGTH);
956    }
957    #[test]
958    fn test_trim_tabs_length() {
959        error_support::init_for_tests();
960        let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload");
961        assert_eq!(storage.prepare_local_tabs_for_upload(), None);
962        let mut too_many_tabs: Vec<RemoteTab> = Vec::new();
963        for n in 1..5000 {
964            too_many_tabs.push(RemoteTab {
965                title: "aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa" //50 characters
966                    .to_owned(),
967                url_history: vec![format!("https://foo{}.bar", n)],
968                ..Default::default()
969            });
970        }
971        let tabs_mem_size = compute_serialized_size(&too_many_tabs).unwrap();
972        // ensure we are definitely over the payload limit
973        assert!(tabs_mem_size > MAX_PAYLOAD_SIZE);
974        // Add our over-the-limit tabs to the local state
975        storage.update_local_state(too_many_tabs.clone());
976        // prepare_local_tabs_for_upload did the trimming we needed to get under payload size
977        let tabs_to_upload = &storage.prepare_local_tabs_for_upload().unwrap();
978        assert!(compute_serialized_size(tabs_to_upload).unwrap() <= MAX_PAYLOAD_SIZE);
979    }
980    // Helper struct to model what's stored in the DB
981    struct TabsSQLRecord {
982        guid: String,
983        record: TabsRecord,
984        last_modified: i64,
985    }
986    #[test]
987    fn test_remove_stale_clients() {
988        error_support::init_for_tests();
989        let dir = tempfile::tempdir().unwrap();
990        let db_name = dir.path().join("test_remove_stale_clients.db");
991        let mut storage = TabsStorage::new(db_name);
992        storage.open_or_create().unwrap();
993        assert!(storage.open_if_exists().unwrap().is_some());
994
995        let records = vec![
996            TabsSQLRecord {
997                guid: "device-1".to_string(),
998                record: TabsRecord {
999                    id: "device-1".to_string(),
1000                    client_name: "Device #1".to_string(),
1001                    tabs: vec![TabsRecordTab {
1002                        title: "the title".to_string(),
1003                        url_history: vec!["https://mozilla.org/".to_string()],
1004                        icon: Some("https://mozilla.org/icon".to_string()),
1005                        last_used: 1643764207000,
1006                        ..Default::default()
1007                    }],
1008                },
1009                last_modified: 1643764207000,
1010            },
1011            TabsSQLRecord {
1012                guid: "device-outdated".to_string(),
1013                record: TabsRecord {
1014                    id: "device-outdated".to_string(),
1015                    client_name: "Device outdated".to_string(),
1016                    tabs: vec![TabsRecordTab {
1017                        title: "the title".to_string(),
1018                        url_history: vec!["https://mozilla.org/".to_string()],
1019                        icon: Some("https://mozilla.org/icon".to_string()),
1020                        last_used: 1643764207000,
1021                        ..Default::default()
1022                    }],
1023                },
1024                last_modified: 1443764207000, // old
1025            },
1026        ];
1027        let db = storage.open_if_exists().unwrap().unwrap();
1028        for record in records {
1029            db.execute(
1030                "INSERT INTO tabs (guid, record, last_modified) VALUES (:guid, :record, :last_modified);",
1031                rusqlite::named_params! {
1032                    ":guid": &record.guid,
1033                    ":record": serde_json::to_string(&record.record).unwrap(),
1034                    ":last_modified": &record.last_modified,
1035                },
1036            ).unwrap();
1037        }
1038        // pretend we just synced
1039        let last_synced = 1643764207000_i64;
1040        storage
1041            .put_meta(schema::LAST_SYNC_META_KEY, &last_synced)
1042            .unwrap();
1043        storage.remove_stale_clients().unwrap();
1044
1045        let remote_tabs = storage.get_remote_tabs().unwrap();
1046        // We should've removed the outdated device
1047        assert_eq!(remote_tabs.len(), 1);
1048        // Assert the correct record is still being returned
1049        assert_eq!(remote_tabs[0].client_id, "device-1");
1050    }
1051
1052    fn pending_url_command(device_id: &str, url: &str, ts: Timestamp) -> PendingCommand {
1053        PendingCommand {
1054            device_id: device_id.to_string(),
1055            command: RemoteCommand::CloseTab {
1056                url: url.to_string(),
1057            },
1058            time_requested: ts,
1059            time_sent: None,
1060        }
1061    }
1062
1063    #[test]
1064    fn test_add_pending_dupe_simple() {
1065        error_support::init_for_tests();
1066        let mut storage = TabsStorage::new_with_mem_path("test_add_pending_dupe_simple");
1067        let command = RemoteCommand::close_tab("https://example1.com");
1068        // returns a bool to say if it's new or not.
1069        assert!(storage
1070            .add_remote_tab_command("device-1", &command)
1071            .expect("should work"));
1072        assert!(!storage
1073            .add_remote_tab_command("device-1", &command)
1074            .expect("should work"));
1075        assert!(storage
1076            .remove_remote_tab_command("device-1", &command)
1077            .expect("should work"));
1078        assert!(storage
1079            .add_remote_tab_command("device-1", &command)
1080            .expect("should work"));
1081    }
1082
1083    #[test]
1084    fn test_add_pending_remote_close() {
1085        error_support::init_for_tests();
1086        let mut storage = TabsStorage::new_with_mem_path("test_add_pending_remote_close");
1087        storage.open_or_create().unwrap();
1088        assert!(storage.open_if_exists().unwrap().is_some());
1089
1090        let now = Timestamp::now();
1091        let earliest = now.checked_sub(Duration::from_millis(1)).unwrap();
1092        let later = now.checked_add(Duration::from_millis(1)).unwrap();
1093        let latest = now.checked_add(Duration::from_millis(2)).unwrap();
1094        // The tabs requested to to be closed. We'll insert them in the "wrong" order
1095        // relative to their time-stamp.
1096        storage
1097            .add_remote_tab_command_at(
1098                "device-1",
1099                &RemoteCommand::close_tab("https://example1.com"),
1100                latest,
1101            )
1102            .expect("should work");
1103        storage
1104            .add_remote_tab_command_at(
1105                "device-1",
1106                &RemoteCommand::close_tab("https://example2.com"),
1107                earliest,
1108            )
1109            .expect("should work");
1110        storage
1111            .add_remote_tab_command_at(
1112                "device-2",
1113                &RemoteCommand::close_tab("https://example2.com"),
1114                now,
1115            )
1116            .expect("should work");
1117        storage
1118            .add_remote_tab_command_at(
1119                "device-2",
1120                &RemoteCommand::close_tab("https://example3.com"),
1121                later,
1122            )
1123            .expect("should work");
1124
1125        let got = storage.get_unsent_commands().unwrap();
1126
1127        assert_eq!(got.len(), 4);
1128        assert_eq!(
1129            got,
1130            vec![
1131                pending_url_command("device-1", "https://example2.com", earliest),
1132                pending_url_command("device-2", "https://example2.com", now),
1133                pending_url_command("device-2", "https://example3.com", later),
1134                pending_url_command("device-1", "https://example1.com", latest),
1135            ]
1136        );
1137    }
1138
1139    #[test]
1140    fn test_remote_tabs_filters_pending_closures() {
1141        error_support::init_for_tests();
1142        let mut storage =
1143            TabsStorage::new_with_mem_path("test_remote_tabs_filters_pending_closures");
1144        let records = vec![
1145            TabsSQLRecord {
1146                guid: "device-1".to_string(),
1147                record: TabsRecord {
1148                    id: "device-1".to_string(),
1149                    client_name: "Device #1".to_string(),
1150                    tabs: vec![TabsRecordTab {
1151                        title: "the title".to_string(),
1152                        url_history: vec!["https://mozilla.org/".to_string()],
1153                        icon: Some("https://mozilla.org/icon".to_string()),
1154                        last_used: 1711929600015, // 4/1/2024
1155                        ..Default::default()
1156                    }],
1157                },
1158                last_modified: 1711929600015, // 4/1/2024
1159            },
1160            TabsSQLRecord {
1161                guid: "device-2".to_string(),
1162                record: TabsRecord {
1163                    id: "device-2".to_string(),
1164                    client_name: "Another device".to_string(),
1165                    tabs: vec![
1166                        TabsRecordTab {
1167                            title: "the title".to_string(),
1168                            url_history: vec!["https://mozilla.org/".to_string()],
1169                            icon: Some("https://mozilla.org/icon".to_string()),
1170                            last_used: 1711929600015, // 4/1/2024
1171                            ..Default::default()
1172                        },
1173                        TabsRecordTab {
1174                            title: "the title".to_string(),
1175                            url_history: vec![
1176                                "https://example.com/".to_string(),
1177                                "https://example1.com/".to_string(),
1178                            ],
1179                            icon: None,
1180                            last_used: 1711929600015, // 4/1/2024
1181                            ..Default::default()
1182                        },
1183                        TabsRecordTab {
1184                            title: "the title".to_string(),
1185                            url_history: vec!["https://example1.com/".to_string()],
1186                            icon: None,
1187                            last_used: 1711929600015, // 4/1/2024
1188                            ..Default::default()
1189                        },
1190                    ],
1191                },
1192                last_modified: 1711929600015, // 4/1/2024
1193            },
1194        ];
1195
1196        let db = storage.open_if_exists().unwrap().unwrap();
1197        for record in records {
1198            db.execute(
1199                "INSERT INTO tabs (guid, record, last_modified) VALUES (:guid, :record, :last_modified);",
1200                rusqlite::named_params! {
1201                    ":guid": &record.guid,
1202                    ":record": serde_json::to_string(&record.record).unwrap(),
1203                    ":last_modified": &record.last_modified,
1204                },
1205            ).unwrap();
1206        }
1207
1208        // Some tabs were requested to be closed
1209        storage
1210            .add_remote_tab_command(
1211                "device-1",
1212                &RemoteCommand::close_tab("https://mozilla.org/"),
1213            )
1214            .unwrap();
1215        storage
1216            .add_remote_tab_command(
1217                "device-2",
1218                &RemoteCommand::close_tab("https://example.com/"),
1219            )
1220            .unwrap();
1221        storage
1222            .add_remote_tab_command(
1223                "device-2",
1224                &RemoteCommand::close_tab("https://example1.com/"),
1225            )
1226            .unwrap();
1227
1228        let remote_tabs = storage.get_remote_tabs().unwrap();
1229
1230        assert_eq!(remote_tabs.len(), 2);
1231
1232        // Device 1 had only 1 tab synced, we remotely closed it, so we expect no tabs
1233        assert_eq!(remote_tabs[0].client_id, "device-1");
1234        assert_eq!(remote_tabs[0].remote_tabs.len(), 0);
1235
1236        // Device 2 had 3 tabs open and we remotely closed 2, so we expect 1 tab returned
1237        assert_eq!(remote_tabs[1].client_id, "device-2");
1238        assert_eq!(remote_tabs[1].remote_tabs.len(), 1);
1239        assert_eq!(
1240            remote_tabs[1].remote_tabs[0],
1241            RemoteTab {
1242                title: "the title".to_string(),
1243                url_history: vec!["https://mozilla.org/".to_string()],
1244                icon: Some("https://mozilla.org/icon".to_string()),
1245                last_used: 1711929600015000, //server time is ns, so 1000 bigger than local.
1246                ..Default::default()
1247            }
1248        );
1249    }
1250
1251    #[test]
1252    fn test_remove_old_pending_closures_timed_removal() {
1253        error_support::init_for_tests();
1254        let mut storage =
1255            TabsStorage::new_with_mem_path("test_remove_old_pending_closures_timed_removal");
1256
1257        let now = Timestamp::now();
1258        let older = now
1259            .checked_sub(Duration::from_millis(REMOTE_COMMAND_TTL_MS))
1260            .unwrap();
1261
1262        {
1263            let db = storage.open_if_exists().unwrap().unwrap();
1264
1265            // We manually insert two devices, one that hasn't updated in awhile and one that's
1266            // updated recently
1267            db.execute(
1268                "INSERT INTO tabs (guid, record, last_modified) VALUES ('device-synced', '', :now);",
1269                rusqlite::named_params! {
1270                    ":now" : now,
1271                },
1272            )
1273            .unwrap();
1274
1275            db.execute(
1276                "INSERT INTO tabs (guid, record, last_modified) VALUES ('device-not-synced', '', :old);",
1277                    rusqlite::named_params! {
1278                        ":old" : older,
1279                    },
1280            ).unwrap();
1281        }
1282        // We also manually insert some pending remote tab closures, we specifically add a recent one
1283        // and one that is 48hrs older since that device updated, which should get removed
1284        storage
1285            .add_remote_tab_command_at(
1286                "device-synced",
1287                &RemoteCommand::close_tab("https://example.com"),
1288                older,
1289            )
1290            .unwrap();
1291
1292        storage
1293            .add_remote_tab_command_at(
1294                "device-not-synced",
1295                &RemoteCommand::close_tab("https://example2.com"),
1296                now,
1297            )
1298            .unwrap();
1299
1300        {
1301            let db = storage.open_if_exists().unwrap().unwrap();
1302
1303            // Verify we actually have 2 pending closures
1304            let before_count: i64 = db
1305                .query_one("SELECT COUNT(*) FROM remote_tab_commands")
1306                .unwrap();
1307            assert_eq!(before_count, 2);
1308        }
1309        // "incoming" records from other devices
1310        let new_records = vec![(
1311            TabsRecord {
1312                id: "device-not-synced".to_string(),
1313                client_name: "".to_string(),
1314                tabs: vec![TabsRecordTab {
1315                    url_history: vec!["https://example2.com".to_string()],
1316                    ..Default::default()
1317                }],
1318            },
1319            ServerTimestamp::from_millis(now.as_millis_i64()),
1320        )];
1321        // Cleanup old pending closures
1322        storage.remove_old_pending_closures(&new_records).unwrap();
1323
1324        let reopen_db = storage.open_if_exists().unwrap().unwrap();
1325        let after_count: i64 = reopen_db
1326            .query_one("SELECT COUNT(*) FROM remote_tab_commands")
1327            .unwrap();
1328        assert_eq!(after_count, 1);
1329
1330        let remaining_device_id: String = reopen_db
1331            .query_one("SELECT device_id FROM remote_tab_commands")
1332            .unwrap();
1333
1334        // Only the device that still hasn't synced keeps
1335        assert_eq!(remaining_device_id, "device-not-synced");
1336    }
1337    #[test]
1338    fn test_remove_old_pending_closures_no_tab_removal() {
1339        error_support::init_for_tests();
1340        let mut storage =
1341            TabsStorage::new_with_mem_path("test_remove_old_pending_closures_no_tab_removal");
1342        let db = storage.open_if_exists().unwrap().unwrap();
1343
1344        let now_ms: u64 = Timestamp::now().as_millis();
1345
1346        // Set up the initial state with tabs that have been synced recently
1347        db.execute(
1348            "INSERT INTO tabs (guid, record, last_modified) VALUES ('device-recent', '', :now);",
1349            rusqlite::named_params! {
1350                ":now": now_ms,
1351            },
1352        )
1353        .unwrap();
1354
1355        // Insert pending closures for a device
1356        db.execute(
1357        "INSERT INTO remote_tab_commands (device_id, command, url, time_requested) VALUES (:device_id, :command, :url, :time_requested)",
1358        rusqlite::named_params! {
1359            ":command": CommandKind::CloseTab,
1360            ":device_id": "device-recent",
1361            ":url": "https://example.com",
1362            ":time_requested": now_ms,
1363        },
1364    ).unwrap();
1365
1366        db.execute(
1367        "INSERT INTO remote_tab_commands (device_id, command, url, time_requested) VALUES (:device_id, :command, :url, :time_requested)",
1368        rusqlite::named_params! {
1369            ":command": CommandKind::CloseTab,
1370            ":device_id": "device-recent",
1371            ":url": "https://old-url.com",
1372            ":time_requested": now_ms,
1373        },
1374    ).unwrap();
1375
1376        // Verify initial state has 2 pending closures
1377        let before_count: i64 = db
1378            .query_row("SELECT COUNT(*) FROM remote_tab_commands", [], |row| {
1379                row.get(0)
1380            })
1381            .unwrap();
1382        assert_eq!(before_count, 2);
1383
1384        // Simulate incoming data that no longer includes one of the URLs
1385        let new_records = vec![(
1386            TabsRecord {
1387                id: "device-recent".to_string(),
1388                client_name: "".to_string(),
1389                tabs: vec![
1390                    TabsRecordTab {
1391                        url_history: vec!["https://example99.com".to_string()],
1392                        ..Default::default()
1393                    },
1394                    TabsRecordTab {
1395                        url_history: vec!["https://example.com".to_string()],
1396                        ..Default::default()
1397                    },
1398                ],
1399            },
1400            ServerTimestamp::default(),
1401        )];
1402
1403        // Perform the cleanup
1404        storage.remove_old_pending_closures(&new_records).unwrap();
1405
1406        // need to reopen db to avoid mutable errors
1407        let reopen_db = storage.open_if_exists().unwrap().unwrap();
1408        // Check results after cleanup
1409        let after_count: i64 = reopen_db
1410            .query_row("SELECT COUNT(*) FROM remote_tab_commands", [], |row| {
1411                row.get(0)
1412            })
1413            .unwrap();
1414        assert_eq!(after_count, 1); // Only one entry should remain
1415
1416        let remaining_url: String = reopen_db
1417            .query_row("SELECT url FROM remote_tab_commands", [], |row| row.get(0))
1418            .unwrap();
1419
1420        assert_eq!(remaining_url, "https://example.com"); // The URL still present in new_records should remain
1421    }
1422
1423    #[test]
1424    fn test_remove_pending_command() {
1425        error_support::init_for_tests();
1426        let mut storage = TabsStorage::new_with_mem_path("test_remove_pending_command");
1427        storage.open_or_create().unwrap();
1428        assert!(storage.open_if_exists().unwrap().is_some());
1429
1430        storage
1431            .add_remote_tab_command(
1432                "device-1",
1433                &RemoteCommand::close_tab("https://example1.com"),
1434            )
1435            .expect("should work");
1436
1437        assert_eq!(storage.get_unsent_commands().unwrap().len(), 1);
1438        assert!(!storage
1439            .remove_remote_tab_command(
1440                "no-devce",
1441                &RemoteCommand::close_tab("https://example1.com"),
1442            )
1443            .unwrap());
1444        assert_eq!(storage.get_unsent_commands().unwrap().len(), 1);
1445
1446        assert!(!storage
1447            .remove_remote_tab_command(
1448                "device-1",
1449                &RemoteCommand::close_tab("https://example9.com"),
1450            )
1451            .unwrap());
1452        assert_eq!(storage.get_unsent_commands().unwrap().len(), 1);
1453
1454        assert!(storage
1455            .remove_remote_tab_command(
1456                "device-1",
1457                &RemoteCommand::close_tab("https://example1.com"),
1458            )
1459            .unwrap());
1460        assert_eq!(storage.get_unsent_commands().unwrap().len(), 0);
1461    }
1462
1463    #[test]
1464    fn test_sent_command() {
1465        error_support::init_for_tests();
1466        let mut storage = TabsStorage::new_with_mem_path("test_sent_command");
1467        let command = RemoteCommand::close_tab("https://example1.com");
1468        storage
1469            .add_remote_tab_command("device-1", &command)
1470            .expect("should work");
1471
1472        assert_eq!(storage.get_unsent_commands().unwrap().len(), 1);
1473        let pending_command = PendingCommand {
1474            device_id: "device-1".to_string(),
1475            command: command.clone(),
1476            time_requested: Timestamp::now(),
1477            time_sent: None,
1478        };
1479        assert!(storage.set_pending_command_sent(&pending_command).unwrap());
1480        assert_eq!(storage.get_unsent_commands().unwrap().len(), 0);
1481        // but can't re-add it because it's still alive.
1482        assert!(!storage
1483            .add_remote_tab_command("device-1", &command)
1484            .unwrap());
1485        // can remove it.
1486        assert!(storage
1487            .remove_remote_tab_command("device-1", &command)
1488            .unwrap());
1489        // now can re-add it.
1490        assert!(storage
1491            .add_remote_tab_command("device-1", &command)
1492            .unwrap());
1493        assert_eq!(storage.get_unsent_commands().unwrap().len(), 1);
1494    }
1495
1496    #[test]
1497    fn test_remove_pending_closures_only_affects_target_device() {
1498        error_support::init_for_tests();
1499        let mut storage =
1500            TabsStorage::new_with_mem_path("test_remove_pending_closures_target_device");
1501        let now = Timestamp::now();
1502
1503        let db = storage.open_if_exists().unwrap().unwrap();
1504
1505        // Insert two devices into the tabs db
1506        db.execute(
1507            "INSERT INTO tabs (guid, record, last_modified) VALUES ('device-1', '', :now);",
1508            rusqlite::named_params! { ":now" : now },
1509        )
1510        .unwrap();
1511
1512        db.execute(
1513            "INSERT INTO tabs (guid, record, last_modified) VALUES ('device-2', '', :now);",
1514            rusqlite::named_params! { ":now" : now },
1515        )
1516        .unwrap();
1517
1518        // Add three commands, two for device-1 and one for device-2
1519        storage
1520            .add_remote_tab_command(
1521                "device-1",
1522                &RemoteCommand::close_tab("https://example1.com"),
1523            )
1524            .unwrap();
1525
1526        storage
1527            .add_remote_tab_command(
1528                "device-1",
1529                &RemoteCommand::close_tab("https://example2.com"),
1530            )
1531            .unwrap();
1532
1533        storage
1534            .add_remote_tab_command(
1535                "device-2",
1536                &RemoteCommand::close_tab("https://example3.com"),
1537            )
1538            .unwrap();
1539
1540        // Pretend only device-1 "synced", example2.com tab was closed
1541        let new_records = vec![(
1542            TabsRecord {
1543                id: "device-1".to_string(),
1544                client_name: "".to_string(),
1545                tabs: vec![TabsRecordTab {
1546                    url_history: vec!["https://example1.com".to_string()],
1547                    ..Default::default()
1548                }],
1549            },
1550            ServerTimestamp::default(),
1551        )];
1552
1553        storage.remove_old_pending_closures(&new_records).unwrap();
1554
1555        let reopen_db = storage.open_if_exists().unwrap().unwrap();
1556        let remaining_commands: Vec<(String, String)> = reopen_db
1557            .prepare("SELECT device_id, url FROM remote_tab_commands")
1558            .unwrap()
1559            .query_map([], |row| {
1560                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1561            })
1562            .unwrap()
1563            .collect::<rusqlite::Result<Vec<_>, _>>()
1564            .unwrap();
1565        // We should only have removed 1 command from the list
1566        assert_eq!(remaining_commands.len(), 2);
1567        assert!(remaining_commands
1568            .contains(&("device-1".to_string(), "https://example1.com".to_string())));
1569        assert!(remaining_commands
1570            .contains(&("device-2".to_string(), "https://example3.com".to_string())));
1571    }
1572
1573    #[test]
1574    fn test_close_connection() {
1575        let dir = tempfile::tempdir().unwrap();
1576        let db_path = dir.path().join("test_close_connection.db");
1577        let mut storage = TabsStorage::new(db_path);
1578
1579        // Open the connection
1580        storage.open_or_create().unwrap();
1581
1582        // Verify that the connection is open
1583        assert!(matches!(storage.db_connection, DbConnection::Open(_)));
1584
1585        // Close the connection
1586        storage.close();
1587
1588        // Verify that the connection is closed
1589        assert!(matches!(storage.db_connection, DbConnection::Closed));
1590
1591        // Attempt to reopen the connection should fail
1592        let result = storage.open_or_create();
1593        assert!(result.is_err());
1594        assert!(matches!(
1595            result.unwrap_err(),
1596            Error::UnexpectedConnectionState
1597        ));
1598    }
1599}