summaryrefslogtreecommitdiffstats
path: root/third_party/rust/tabs/src/storage.rs
blob: 0522ed37f487e325eddae60925d5d7983c378abc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// From https://searchfox.org/mozilla-central/rev/ea63a0888d406fae720cf24f4727d87569a8cab5/services/sync/modules/constants.js#75
const URI_LENGTH_MAX: usize = 65536;
// https://searchfox.org/mozilla-central/rev/ea63a0888d406fae720cf24f4727d87569a8cab5/services/sync/modules/engines/tabs.js#8
const TAB_ENTRIES_LIMIT: usize = 5;

use crate::error::*;
use crate::DeviceType;
use rusqlite::{Connection, OpenFlags};
use serde_derive::{Deserialize, Serialize};
use sql_support::open_database::{self, open_database_with_flags};
use sql_support::ConnExt;
use std::cell::RefCell;
use std::path::{Path, PathBuf};

pub type TabsDeviceType = crate::DeviceType;
pub type RemoteTabRecord = RemoteTab;

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteTab {
    pub title: String,
    pub url_history: Vec<String>,
    pub icon: Option<String>,
    pub last_used: i64, // In ms.
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClientRemoteTabs {
    pub client_id: String, // Corresponds to the `clients` collection ID of the client.
    pub client_name: String,
    #[serde(
        default = "devicetype_default_deser",
        skip_serializing_if = "devicetype_is_unknown"
    )]
    pub device_type: DeviceType,
    // serde default so we can read old rows that didn't persist this.
    #[serde(default)]
    pub last_modified: i64,
    pub remote_tabs: Vec<RemoteTab>,
}

fn devicetype_default_deser() -> DeviceType {
    // replace with `DeviceType::default_deser` once #4861 lands.
    DeviceType::Unknown
}

// Unlike most other uses-cases, here we do allow serializing ::Unknown, but skip it.
fn devicetype_is_unknown(val: &DeviceType) -> bool {
    matches!(val, DeviceType::Unknown)
}

// Tabs has unique requirements for storage:
// * The "local_tabs" exist only so we can sync them out. There's no facility to
//   query "local tabs", so there's no need to store these persistently - ie, they
//   are write-only.
// * The "remote_tabs" exist purely for incoming items via sync - there's no facility
//   to set them locally - they are read-only.
// Note that this means a database is only actually needed after Sync fetches remote tabs,
// and because sync users are in the minority, the use of a database here is purely
// optional and created on demand. The implication here is that asking for the "remote tabs"
// when no database exists is considered a normal situation and just implies no remote tabs exist.
// (Note however we don't attempt to remove the database when no remote tabs exist, so having
// no remote tabs in an existing DB is also a normal situation)
pub struct TabsStorage {
    local_tabs: RefCell<Option<Vec<RemoteTab>>>,
    db_path: PathBuf,
    db_connection: Option<Connection>,
}

impl TabsStorage {
    pub fn new(db_path: impl AsRef<Path>) -> Self {
        Self {
            local_tabs: RefCell::default(),
            db_path: db_path.as_ref().to_path_buf(),
            db_connection: None,
        }
    }

    /// Arrange for a new memory-based TabsStorage. As per other DB semantics, creating
    /// this isn't enough to actually create the db!
    pub fn new_with_mem_path(db_path: &str) -> Self {
        let name = PathBuf::from(format!("file:{}?mode=memory&cache=shared", db_path));
        Self::new(name)
    }

    /// If a DB file exists, open and return it.
    pub fn open_if_exists(&mut self) -> Result<Option<&Connection>> {
        if let Some(ref existing) = self.db_connection {
            return Ok(Some(existing));
        }
        let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
            | OpenFlags::SQLITE_OPEN_URI
            | OpenFlags::SQLITE_OPEN_READ_WRITE;
        match open_database_with_flags(
            self.db_path.clone(),
            flags,
            &crate::schema::TabsMigrationLogin,
        ) {
            Ok(conn) => {
                self.db_connection = Some(conn);
                Ok(self.db_connection.as_ref())
            }
            Err(open_database::Error::SqlError(rusqlite::Error::SqliteFailure(code, _)))
                if code.code == rusqlite::ErrorCode::CannotOpen =>
            {
                Ok(None)
            }
            Err(e) => Err(e.into()),
        }
    }

    /// Open and return the DB, creating it if necessary.
    pub fn open_or_create(&mut self) -> Result<&Connection> {
        if let Some(ref existing) = self.db_connection {
            return Ok(existing);
        }
        let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
            | OpenFlags::SQLITE_OPEN_URI
            | OpenFlags::SQLITE_OPEN_READ_WRITE
            | OpenFlags::SQLITE_OPEN_CREATE;
        let conn = open_database_with_flags(
            self.db_path.clone(),
            flags,
            &crate::schema::TabsMigrationLogin,
        )?;
        self.db_connection = Some(conn);
        Ok(self.db_connection.as_ref().unwrap())
    }

    pub fn update_local_state(&mut self, local_state: Vec<RemoteTab>) {
        self.local_tabs.borrow_mut().replace(local_state);
    }

    pub fn prepare_local_tabs_for_upload(&self) -> Option<Vec<RemoteTab>> {
        if let Some(local_tabs) = self.local_tabs.borrow().as_ref() {
            return Some(
                local_tabs
                    .iter()
                    .cloned()
                    .filter_map(|mut tab| {
                        if tab.url_history.is_empty() || !is_url_syncable(&tab.url_history[0]) {
                            return None;
                        }
                        let mut sanitized_history = Vec::with_capacity(TAB_ENTRIES_LIMIT);
                        for url in tab.url_history {
                            if sanitized_history.len() == TAB_ENTRIES_LIMIT {
                                break;
                            }
                            if is_url_syncable(&url) {
                                sanitized_history.push(url);
                            }
                        }
                        tab.url_history = sanitized_history;
                        Some(tab)
                    })
                    .collect(),
            );
        }
        None
    }

    pub fn get_remote_tabs(&mut self) -> Option<Vec<ClientRemoteTabs>> {
        match self.open_if_exists() {
            Err(e) => {
                error_support::report_error!(
                    "tabs-read-remote",
                    "Failed to read remote tabs: {}",
                    e
                );
                None
            }
            Ok(None) => None,
            Ok(Some(c)) => {
                match c.query_rows_and_then_cached(
                    "SELECT payload FROM tabs",
                    [],
                    |row| -> Result<_> { Ok(serde_json::from_str(&row.get::<_, String>(0)?)?) },
                ) {
                    Ok(crts) => Some(crts),
                    Err(e) => {
                        error_support::report_error!(
                            "tabs-read-remote",
                            "Failed to read database: {}",
                            e
                        );
                        None
                    }
                }
            }
        }
    }
}

impl TabsStorage {
    pub(crate) fn replace_remote_tabs(
        &mut self,
        new_remote_tabs: Vec<ClientRemoteTabs>,
    ) -> Result<()> {
        let connection = self.open_or_create()?;
        let tx = connection.unchecked_transaction()?;
        // delete the world - we rebuild it from scratch every sync.
        tx.execute_batch("DELETE FROM tabs")?;

        for crt in new_remote_tabs {
            tx.execute_cached(
                "INSERT INTO tabs (payload) VALUES (:payload);",
                rusqlite::named_params! {
                    ":payload": serde_json::to_string(&crt).expect("tabs don't fail to serialize"),
                },
            )?;
        }
        tx.commit()?;
        Ok(())
    }

    pub(crate) fn wipe_remote_tabs(&mut self) -> Result<()> {
        if let Some(db) = self.open_if_exists()? {
            db.execute_batch("DELETE FROM tabs")?;
        }
        Ok(())
    }

    pub(crate) fn wipe_local_tabs(&self) {
        self.local_tabs.replace(None);
    }
}

fn is_url_syncable(url: &str) -> bool {
    url.len() <= URI_LENGTH_MAX
        && !(url.starts_with("about:")
            || url.starts_with("resource:")
            || url.starts_with("chrome:")
            || url.starts_with("wyciwyg:")
            || url.starts_with("blob:")
            || url.starts_with("file:")
            || url.starts_with("moz-extension:"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_url_syncable() {
        assert!(is_url_syncable("https://bobo.com"));
        assert!(is_url_syncable("ftp://bobo.com"));
        assert!(!is_url_syncable("about:blank"));
        // XXX - this smells wrong - we should insist on a valid complete URL?
        assert!(is_url_syncable("aboutbobo.com"));
        assert!(!is_url_syncable("file:///Users/eoger/bobo"));
    }

    #[test]
    fn test_open_if_exists_no_file() {
        let dir = tempfile::tempdir().unwrap();
        let db_name = dir.path().join("test_open_for_read_no_file.db");
        let mut storage = TabsStorage::new(db_name.clone());
        assert!(storage.open_if_exists().unwrap().is_none());
        storage.open_or_create().unwrap(); // will have created it.
                                           // make a new storage, but leave the file alone.
        let mut storage = TabsStorage::new(db_name);
        // db file exists, so opening for read should open it.
        assert!(storage.open_if_exists().unwrap().is_some());
    }

    #[test]
    fn test_prepare_local_tabs_for_upload() {
        let mut storage = TabsStorage::new_with_mem_path("test_prepare_local_tabs_for_upload");
        assert_eq!(storage.prepare_local_tabs_for_upload(), None);
        storage.update_local_state(vec![
            RemoteTab {
                title: "".to_owned(),
                url_history: vec!["about:blank".to_owned(), "https://foo.bar".to_owned()],
                icon: None,
                last_used: 0,
            },
            RemoteTab {
                title: "".to_owned(),
                url_history: vec![
                    "https://foo.bar".to_owned(),
                    "about:blank".to_owned(),
                    "about:blank".to_owned(),
                    "about:blank".to_owned(),
                    "about:blank".to_owned(),
                    "about:blank".to_owned(),
                    "about:blank".to_owned(),
                    "about:blank".to_owned(),
                ],
                icon: None,
                last_used: 0,
            },
            RemoteTab {
                title: "".to_owned(),
                url_history: vec![
                    "https://foo.bar".to_owned(),
                    "about:blank".to_owned(),
                    "https://foo2.bar".to_owned(),
                    "https://foo3.bar".to_owned(),
                    "https://foo4.bar".to_owned(),
                    "https://foo5.bar".to_owned(),
                    "https://foo6.bar".to_owned(),
                ],
                icon: None,
                last_used: 0,
            },
            RemoteTab {
                title: "".to_owned(),
                url_history: vec![],
                icon: None,
                last_used: 0,
            },
        ]);
        assert_eq!(
            storage.prepare_local_tabs_for_upload(),
            Some(vec![
                RemoteTab {
                    title: "".to_owned(),
                    url_history: vec!["https://foo.bar".to_owned()],
                    icon: None,
                    last_used: 0,
                },
                RemoteTab {
                    title: "".to_owned(),
                    url_history: vec![
                        "https://foo.bar".to_owned(),
                        "https://foo2.bar".to_owned(),
                        "https://foo3.bar".to_owned(),
                        "https://foo4.bar".to_owned(),
                        "https://foo5.bar".to_owned()
                    ],
                    icon: None,
                    last_used: 0,
                },
            ])
        );
    }

    #[test]
    fn test_old_client_remote_tabs_version() {
        env_logger::try_init().ok();
        // The initial version of ClientRemoteTabs which we persisted looks like:
        let old = serde_json::json!({
            "client_id": "id",
            "client_name": "name",
            "remote_tabs": [
                serde_json::json!({
                    "title": "tab title",
                    "url_history": ["url"],
                    "last_used": 1234,
                }),
            ]
        });

        let dir = tempfile::tempdir().unwrap();
        let db_name = dir.path().join("test_old_client_remote_tabs_version.db");
        let mut storage = TabsStorage::new(db_name);

        let connection = storage.open_or_create().expect("should create");
        connection
            .execute_cached(
                "INSERT INTO tabs (payload) VALUES (:payload);",
                rusqlite::named_params! {
                    ":payload": serde_json::to_string(&old).expect("tabs don't fail to serialize"),
                },
            )
            .expect("should insert");

        // We should be able to read it out.
        let clients = storage.get_remote_tabs().expect("should work");
        assert_eq!(clients.len(), 1, "must be 1 tab");
        let client = &clients[0];
        assert_eq!(client.client_id, "id");
        assert_eq!(client.client_name, "name");
        assert_eq!(client.remote_tabs.len(), 1);
        assert_eq!(client.remote_tabs[0].title, "tab title");
        assert_eq!(client.remote_tabs[0].url_history, vec!["url".to_string()]);
        assert_eq!(client.remote_tabs[0].last_used, 1234);

        // The old version didn't have last_modified - check it is the default.
        assert_eq!(client.last_modified, 0);
    }
}