From 2604d606364a9886a0be3fad2cf4f4d41926498b Mon Sep 17 00:00:00 2001 From: Johannes Salas Schmidt Date: Tue, 7 Apr 2026 12:02:18 +0200 Subject: [PATCH 1/3] breach alert component skeleton --- Cargo.lock | 7 +++++++ Cargo.toml | 2 ++ components/breach-alerts/Cargo.toml | 7 +++++++ components/breach-alerts/README.md | 5 +++++ components/breach-alerts/src/lib.rs | 14 ++++++++++++++ 5 files changed, 35 insertions(+) create mode 100644 components/breach-alerts/Cargo.toml create mode 100644 components/breach-alerts/README.md create mode 100644 components/breach-alerts/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 13269b3f5c..cdbd424fe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -383,6 +383,13 @@ dependencies = [ "generic-array", ] +[[package]] +name = "breach-alerts" +version = "0.1.0" +dependencies = [ + "uniffi", +] + [[package]] name = "bumpalo" version = "3.16.0" diff --git a/Cargo.toml b/Cargo.toml index 9f753f9845..b4b1212b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "components/ads-client/integration-tests", "components/as-ohttp-client", "components/autofill", + "components/breach-alerts", "components/context_id", "components/crashtest", "components/example", @@ -109,6 +110,7 @@ default-members = [ "components/ads-client", "components/as-ohttp-client", "components/autofill", + "components/breach-alerts", "components/context_id", "components/crashtest", "components/fxa-client", diff --git a/components/breach-alerts/Cargo.toml b/components/breach-alerts/Cargo.toml new file mode 100644 index 0000000000..f1637d8655 --- /dev/null +++ b/components/breach-alerts/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "breach-alerts" +version = "0.1.0" +edition = "2024" + +[dependencies] +uniffi = { version = "0.31" } diff --git a/components/breach-alerts/README.md b/components/breach-alerts/README.md new file mode 100644 index 0000000000..d6604683fb --- /dev/null +++ b/components/breach-alerts/README.md @@ -0,0 +1,5 @@ +# breach-alerts + +Stores and retrieves breach alert dismissals by breach ID. + +> Work in progress. diff --git a/components/breach-alerts/src/lib.rs b/components/breach-alerts/src/lib.rs new file mode 100644 index 0000000000..b93cf3ffd9 --- /dev/null +++ b/components/breach-alerts/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} From 912894e0b644c7d5fe4ff79edcdbc4c430253e4a Mon Sep 17 00:00:00 2001 From: Johannes Salas Schmidt Date: Tue, 7 Apr 2026 13:27:35 +0200 Subject: [PATCH 2/3] breach alert database setup extracted from webext-storage --- Cargo.lock | 9 + components/breach-alerts/Cargo.toml | 16 +- .../breach-alerts/sql/create_schema.sql | 9 + components/breach-alerts/src/db.rs | 272 ++++++++++++++++++ components/breach-alerts/src/error.rs | 30 ++ components/breach-alerts/src/lib.rs | 23 +- components/breach-alerts/src/schema.rs | 56 ++++ components/breach-alerts/src/store.rs | 62 ++++ 8 files changed, 464 insertions(+), 13 deletions(-) create mode 100644 components/breach-alerts/sql/create_schema.sql create mode 100644 components/breach-alerts/src/db.rs create mode 100644 components/breach-alerts/src/error.rs create mode 100644 components/breach-alerts/src/schema.rs create mode 100644 components/breach-alerts/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index cdbd424fe6..35cd5ded17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,7 +387,16 @@ dependencies = [ name = "breach-alerts" version = "0.1.0" dependencies = [ + "error-support", + "interrupt-support", + "parking_lot", + "rusqlite", + "serde", + "sql-support", + "tempfile", + "thiserror 2.0.3", "uniffi", + "url", ] [[package]] diff --git a/components/breach-alerts/Cargo.toml b/components/breach-alerts/Cargo.toml index f1637d8655..3f99eeca25 100644 --- a/components/breach-alerts/Cargo.toml +++ b/components/breach-alerts/Cargo.toml @@ -1,7 +1,21 @@ [package] name = "breach-alerts" +edition = "2021" version = "0.1.0" -edition = "2024" +authors = ["sync-team@mozilla.com"] +license = "MPL-2.0" [dependencies] uniffi = { version = "0.31" } +error-support = { path = "../support/error" } +interrupt-support = { path = "../support/interrupt" } +parking_lot = ">=0.11,<=0.12" +rusqlite = { version = "0.37.0", features = ["functions", "bundled", "unlock_notify"] } +serde = "1" +sql-support = { path = "../support/sql" } +thiserror = "2" +url = { version = "2.1" } + +[dev-dependencies] +error-support = { path = "../support/error", features = ["testing"] } +tempfile = "3" diff --git a/components/breach-alerts/sql/create_schema.sql b/components/breach-alerts/sql/create_schema.sql new file mode 100644 index 0000000000..fde2be1f4d --- /dev/null +++ b/components/breach-alerts/sql/create_schema.sql @@ -0,0 +1,9 @@ +-- 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/. + +-- This table holds key-value metadata - primarily for sync. +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value NOT NULL +) WITHOUT ROWID; diff --git a/components/breach-alerts/src/db.rs b/components/breach-alerts/src/db.rs new file mode 100644 index 0000000000..a737817219 --- /dev/null +++ b/components/breach-alerts/src/db.rs @@ -0,0 +1,272 @@ +/* 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/. */ + +use crate::error::*; +use crate::schema; +use interrupt_support::{SqlInterruptHandle, SqlInterruptScope}; +use parking_lot::Mutex; +use rusqlite::types::{FromSql, ToSql}; +use rusqlite::Connection; +use rusqlite::OpenFlags; +use sql_support::open_database::open_database_with_flags; +use sql_support::ConnExt; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use url::Url; + +/// The inner database connection state, allowing graceful close handling. +pub enum BreachAlertsDbInner { + Open(Connection), + Closed, +} + +pub struct BreachAlertsDb { + pub writer: BreachAlertsDbInner, + interrupt_handle: Arc, +} + +impl BreachAlertsDb { + /// Create a new, or fetch an already open, BreachAlertsDb backed by a file on disk. + pub fn new(db_path: impl AsRef) -> Result { + let db_path = normalize_path(db_path)?; + Self::new_named(db_path) + } + + /// Create a new, or fetch an already open, memory-based BreachAlertsDb. + #[cfg(test)] + pub fn new_memory(db_path: &str) -> Result { + let name = PathBuf::from(format!("file:{}?mode=memory&cache=shared", db_path)); + Self::new_named(name) + } + + fn new_named(db_path: PathBuf) -> Result { + // We always create the read-write connection for an initial open so + // we can create the schema and/or do version upgrades. + let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_READ_WRITE; + + let conn = open_database_with_flags( + db_path, + flags, + &schema::BreachAlertsConnectionInitializer, + )?; + Ok(Self { + interrupt_handle: Arc::new(SqlInterruptHandle::new(&conn)), + writer: BreachAlertsDbInner::Open(conn), + }) + } + + pub fn interrupt_handle(&self) -> Arc { + Arc::clone(&self.interrupt_handle) + } + + #[allow(dead_code)] + pub fn begin_interrupt_scope(&self) -> Result { + Ok(self.interrupt_handle.begin_interrupt_scope()?) + } + + /// Closes the database connection. If there are any unfinalized prepared + /// statements on the connection, `close` will fail and the connection + /// will be leaked. + pub fn close(&mut self) -> Result<()> { + let conn = match std::mem::replace(&mut self.writer, BreachAlertsDbInner::Closed) { + BreachAlertsDbInner::Open(conn) => conn, + BreachAlertsDbInner::Closed => return Ok(()), + }; + conn.close().map_err(|(_, y)| Error::SqlError(y)) + } + + pub(crate) fn get_connection(&self) -> Result<&Connection> { + match &self.writer { + BreachAlertsDbInner::Open(y) => Ok(y), + BreachAlertsDbInner::Closed => Err(Error::DatabaseConnectionClosed), + } + } +} + +// We almost exclusively use this ThreadSafeBreachAlertsDb +pub struct ThreadSafeBreachAlertsDb { + db: Mutex, + interrupt_handle: Arc, +} + +impl ThreadSafeBreachAlertsDb { + pub fn new(db: BreachAlertsDb) -> Self { + Self { + interrupt_handle: db.interrupt_handle(), + db: Mutex::new(db), + } + } + + pub fn interrupt_handle(&self) -> Arc { + Arc::clone(&self.interrupt_handle) + } + + #[allow(dead_code)] + pub fn begin_interrupt_scope(&self) -> Result { + Ok(self.interrupt_handle.begin_interrupt_scope()?) + } +} + +// Deref to a Mutex, which is how we will use ThreadSafeBreachAlertsDb most of the time +impl Deref for ThreadSafeBreachAlertsDb { + type Target = Mutex; + + #[inline] + fn deref(&self) -> &Mutex { + &self.db + } +} + +// Also implement AsRef so that we can interrupt this at shutdown +impl AsRef for ThreadSafeBreachAlertsDb { + fn as_ref(&self) -> &SqlInterruptHandle { + &self.interrupt_handle + } +} + +pub fn put_meta(db: &Connection, key: &str, value: &dyn ToSql) -> Result<()> { + db.conn().execute_cached( + "REPLACE INTO meta (key, value) VALUES (:key, :value)", + rusqlite::named_params! { ":key": key, ":value": value }, + )?; + Ok(()) +} + +pub fn get_meta(db: &Connection, key: &str) -> Result> { + let res = db.conn().try_query_one( + "SELECT value FROM meta WHERE key = :key", + &[(":key", &key)], + true, + )?; + Ok(res) +} + +pub fn delete_meta(db: &Connection, key: &str) -> Result<()> { + db.conn() + .execute_cached("DELETE FROM meta WHERE key = :key", &[(":key", &key)])?; + Ok(()) +} + +// Utilities for working with paths. +// (From places_utils - ideally these would be shared, but the use of +// ErrorKind values makes that non-trivial. + +/// `Path` is basically just a `str` with no validation, and so in practice it +/// could contain a file URL. Rusqlite takes advantage of this a bit, and says +/// `AsRef` but really means "anything sqlite can take as an argument". +/// +/// Swift loves using file urls (the only support it has for file manipulation +/// is through file urls), so it's handy to support them if possible. +fn unurl_path(p: impl AsRef) -> PathBuf { + p.as_ref() + .to_str() + .and_then(|s| Url::parse(s).ok()) + .and_then(|u| { + if u.scheme() == "file" { + u.to_file_path().ok() + } else { + None + } + }) + .unwrap_or_else(|| p.as_ref().to_owned()) +} + +/// If `p` is a file URL, return it, otherwise try and make it one. +/// +/// Errors if `p` is a relative non-url path, or if it's a URL path +/// that's isn't a `file:` URL. +#[allow(dead_code)] +pub fn ensure_url_path(p: impl AsRef) -> Result { + if let Some(u) = p.as_ref().to_str().and_then(|s| Url::parse(s).ok()) { + if u.scheme() == "file" { + Ok(u) + } else { + Err(Error::IllegalDatabasePath(p.as_ref().to_owned())) + } + } else { + let p = p.as_ref(); + let u = Url::from_file_path(p).map_err(|_| Error::IllegalDatabasePath(p.to_owned()))?; + Ok(u) + } +} + +/// As best as possible, convert `p` into an absolute path, resolving +/// all symlinks along the way. +/// +/// If `p` is a file url, it's converted to a path before this. +fn normalize_path(p: impl AsRef) -> Result { + let path = unurl_path(p); + if let Ok(canonical) = path.canonicalize() { + return Ok(canonical); + } + // It probably doesn't exist yet. This is an error, although it seems to + // work on some systems. + // + // We resolve this by trying to canonicalize the parent directory, and + // appending the requested file name onto that. If we can't canonicalize + // the parent, we return an error. + // + // Also, we return errors if the path ends in "..", if there is no + // parent directory, etc. + let file_name = path + .file_name() + .ok_or_else(|| Error::IllegalDatabasePath(path.clone()))?; + + let parent = path + .parent() + .ok_or_else(|| Error::IllegalDatabasePath(path.clone()))?; + + let mut canonical = parent.canonicalize()?; + canonical.push(file_name); + Ok(canonical) +} + +// Helpers for tests +#[cfg(test)] +pub mod test { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // A helper for our tests to get their own memory Api. + static ATOMIC_COUNTER: AtomicUsize = AtomicUsize::new(0); + + pub fn new_mem_db() -> BreachAlertsDb { + error_support::init_for_tests(); + let counter = ATOMIC_COUNTER.fetch_add(1, Ordering::Relaxed); + BreachAlertsDb::new_memory(&format!("test-breach-alerts-{}", counter)) + .expect("should get a db") + } + + pub fn new_mem_thread_safe_db() -> Arc { + Arc::new(ThreadSafeBreachAlertsDb::new(new_mem_db())) + } +} + +#[cfg(test)] +mod tests { + use super::test::*; + use super::*; + + // Sanity check that we can create a database. + #[test] + fn test_open() { + new_mem_db(); + } + + #[test] + fn test_meta() -> Result<()> { + let db = new_mem_db(); + let conn = &db.get_connection()?; + assert_eq!(get_meta::(conn, "foo")?, None); + put_meta(conn, "foo", &"bar".to_string())?; + assert_eq!(get_meta(conn, "foo")?, Some("bar".to_string())); + delete_meta(conn, "foo")?; + assert_eq!(get_meta::(conn, "foo")?, None); + Ok(()) + } +} diff --git a/components/breach-alerts/src/error.rs b/components/breach-alerts/src/error.rs new file mode 100644 index 0000000000..948f288039 --- /dev/null +++ b/components/breach-alerts/src/error.rs @@ -0,0 +1,30 @@ +/* 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/. */ + +pub use error_support::{debug, error, info, trace, warn}; + +use interrupt_support::Interrupted; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Error executing SQL: {0}")] + SqlError(#[from] rusqlite::Error), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Operation interrupted")] + InterruptedError(#[from] Interrupted), + + #[error("Illegal database path: {0:?}")] + IllegalDatabasePath(std::path::PathBuf), + + #[error("Error opening database: {0}")] + OpenDatabaseError(#[from] sql_support::open_database::Error), + + #[error("The storage database has been closed")] + DatabaseConnectionClosed, +} diff --git a/components/breach-alerts/src/lib.rs b/components/breach-alerts/src/lib.rs index b93cf3ffd9..86b8247de8 100644 --- a/components/breach-alerts/src/lib.rs +++ b/components/breach-alerts/src/lib.rs @@ -1,14 +1,13 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +/* 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/. */ -#[cfg(test)] -mod tests { - use super::*; +#![allow(unknown_lints)] +#![warn(rust_2018_idioms)] - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod db; +pub mod error; +mod schema; +pub mod store; + +pub use crate::store::BreachAlertsStore; diff --git a/components/breach-alerts/src/schema.rs b/components/breach-alerts/src/schema.rs new file mode 100644 index 0000000000..e44f1cd1fd --- /dev/null +++ b/components/breach-alerts/src/schema.rs @@ -0,0 +1,56 @@ +/* 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/. */ + +use crate::error::debug; +use rusqlite::{Connection, Transaction}; +use sql_support::open_database::{ + ConnectionInitializer as MigrationLogic, Error as MigrationError, Result as MigrationResult, +}; + +const CREATE_SCHEMA_SQL: &str = include_str!("../sql/create_schema.sql"); + +pub struct BreachAlertsConnectionInitializer; + +impl MigrationLogic for BreachAlertsConnectionInitializer { + const NAME: &'static str = "breach alerts db"; + const END_VERSION: u32 = 1; + + 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! + PRAGMA foreign_keys = ON; + "; + conn.execute_batch(initial_pragmas)?; + conn.set_prepared_statement_cache_capacity(128); + Ok(()) + } + + fn init(&self, db: &Transaction<'_>) -> MigrationResult<()> { + debug!("Creating schema"); + db.execute_batch(CREATE_SCHEMA_SQL)?; + Ok(()) + } + + fn upgrade_from(&self, _db: &Transaction<'_>, version: u32) -> MigrationResult<()> { + Err(MigrationError::IncompatibleVersion(version)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test::new_mem_db; + + #[test] + fn test_create_schema_twice() { + let db = new_mem_db(); + let conn = db.get_connection().expect("should retrieve connection"); + conn.execute_batch(CREATE_SCHEMA_SQL) + .expect("should allow running twice"); + } +} diff --git a/components/breach-alerts/src/store.rs b/components/breach-alerts/src/store.rs new file mode 100644 index 0000000000..4e636d0745 --- /dev/null +++ b/components/breach-alerts/src/store.rs @@ -0,0 +1,62 @@ +/* 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/. */ + +use crate::db::{BreachAlertsDb, ThreadSafeBreachAlertsDb}; +use crate::error::*; +use std::path::Path; +use std::sync::Arc; + +use interrupt_support::SqlInterruptHandle; + +/// A store for managing breach alert data. It manages an underlying +/// database connection and exposes methods for reading and writing +/// breach alert dismissals. +/// +/// An application should create only one store, and manage the instance +/// as a singleton. +pub struct BreachAlertsStore { + pub(crate) db: Arc, +} + +impl BreachAlertsStore { + /// Creates a store backed by a database at `db_path`. The path can be a + /// file path or `file:` URI. + pub fn new(db_path: impl AsRef) -> Result { + let db = BreachAlertsDb::new(db_path)?; + Ok(Self { + db: Arc::new(ThreadSafeBreachAlertsDb::new(db)), + }) + } + + /// Creates a store backed by an in-memory database. + #[cfg(test)] + pub fn new_memory(db_path: &str) -> Result { + let db = BreachAlertsDb::new_memory(db_path)?; + Ok(Self { + db: Arc::new(ThreadSafeBreachAlertsDb::new(db)), + }) + } + + /// Returns an interrupt handle for this store. + pub fn interrupt_handle(&self) -> Arc { + self.db.interrupt_handle() + } + + /// Closes the store and its database connection. + pub fn close(&self) -> Result<()> { + let mut db = self.db.lock(); + db.close() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_send() { + fn ensure_send() {} + ensure_send::(); + } +} From bf87a2919d8ad3f17f8402e33cf2ae5514e01edc Mon Sep 17 00:00:00 2001 From: Johannes Salas Schmidt Date: Tue, 7 Apr 2026 13:44:15 +0200 Subject: [PATCH 3/3] breach alert dismissals api --- CHANGELOG.md | 3 + Cargo.lock | 1 - README.md | 1 + components/breach-alerts/Cargo.toml | 1 - components/breach-alerts/README.md | 7 +- .../breach-alerts/sql/create_schema.sql | 7 +- components/breach-alerts/src/api.rs | 210 ++++++++++++++++++ components/breach-alerts/src/db.rs | 30 +-- components/breach-alerts/src/error.rs | 25 ++- components/breach-alerts/src/lib.rs | 5 + components/breach-alerts/src/store.rs | 60 ++++- 11 files changed, 316 insertions(+), 34 deletions(-) create mode 100644 components/breach-alerts/src/api.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c714c34834..fa9b439fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ ### Logins - New `allow_empty_passwords` feature flag to allow storing logins with empty passwords. This feature is intended to be enabled on desktop during the migration. +### Breach Alerts +- New component: `breach-alerts` for storing and retrieving breach alert dismissals by breach ID. + [Full Changelog](In progress) # v150.0 (_2026-03-23_) diff --git a/Cargo.lock b/Cargo.lock index 35cd5ded17..a676c5ac60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,7 +391,6 @@ dependencies = [ "interrupt-support", "parking_lot", "rusqlite", - "serde", "sql-support", "tempfile", "thiserror 2.0.3", diff --git a/README.md b/README.md index e1c9fa4856..427f180a56 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ by the [uniffi](https://github.com/mozilla/uniffi-rs/) library. * [ads-client](/components/ads-client) - for fetching ads via UAPI * [autofill](components/autofill) - for storage and syncing of credit card and address information +* [breach-alerts](components/breach-alerts) - for breach alert interaction data * [crashtest](components/crashtest) - testing-purposes (crashing the Rust code) * [fxa-client](components/fxa-client) - for applications that need to sign in with FxA, access encryption keys for sync, and more. diff --git a/components/breach-alerts/Cargo.toml b/components/breach-alerts/Cargo.toml index 3f99eeca25..5b98b45d0b 100644 --- a/components/breach-alerts/Cargo.toml +++ b/components/breach-alerts/Cargo.toml @@ -11,7 +11,6 @@ error-support = { path = "../support/error" } interrupt-support = { path = "../support/interrupt" } parking_lot = ">=0.11,<=0.12" rusqlite = { version = "0.37.0", features = ["functions", "bundled", "unlock_notify"] } -serde = "1" sql-support = { path = "../support/sql" } thiserror = "2" url = { version = "2.1" } diff --git a/components/breach-alerts/README.md b/components/breach-alerts/README.md index d6604683fb..a7b5e12b51 100644 --- a/components/breach-alerts/README.md +++ b/components/breach-alerts/README.md @@ -2,4 +2,9 @@ Stores and retrieves breach alert dismissals by breach ID. -> Work in progress. +## API + +- `get_breach_alert_dismissals(breach_ids)` — returns matching dismissals +- `set_breach_alert_dismissals(dismissals)` — upserts dismissals +- `clear_breach_alert_dismissals(breach_ids)` — deletes specific dismissals +- `clear_all_breach_alert_dismissals()` — deletes all dismissals diff --git a/components/breach-alerts/sql/create_schema.sql b/components/breach-alerts/sql/create_schema.sql index fde2be1f4d..b2140e922c 100644 --- a/components/breach-alerts/sql/create_schema.sql +++ b/components/breach-alerts/sql/create_schema.sql @@ -2,7 +2,12 @@ -- 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/. --- This table holds key-value metadata - primarily for sync. +CREATE TABLE IF NOT EXISTS breach_alert_dismissals ( + breach_id TEXT NOT NULL PRIMARY KEY, + dismissed_at INTEGER NOT NULL +); + +-- This table holds key-value metadata. CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value NOT NULL diff --git a/components/breach-alerts/src/api.rs b/components/breach-alerts/src/api.rs new file mode 100644 index 0000000000..19a41ab738 --- /dev/null +++ b/components/breach-alerts/src/api.rs @@ -0,0 +1,210 @@ +/* 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/. */ + +use crate::error::*; +use rusqlite::Connection; +use sql_support::ConnExt; + +#[derive(Debug, Clone, PartialEq, uniffi::Record)] +pub struct BreachAlertDismissal { + pub breach_id: String, + pub dismissed_at: i64, +} + +pub fn get_breach_alert_dismissals( + conn: &Connection, + breach_ids: &[String], +) -> Result> { + if breach_ids.is_empty() { + return Ok(vec![]); + } + let sql = format!( + "SELECT breach_id, dismissed_at FROM breach_alert_dismissals WHERE breach_id IN ({})", + sql_support::repeat_sql_vars(breach_ids.len()) + ); + let mut stmt = conn.conn().prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(breach_ids), |row| { + Ok(BreachAlertDismissal { + breach_id: row.get(0)?, + dismissed_at: row.get(1)?, + }) + })?; + rows.collect::, _>>() + .map_err(Error::from) +} + +pub fn set_breach_alert_dismissals( + conn: &Connection, + dismissals: &[BreachAlertDismissal], +) -> Result<()> { + if dismissals.is_empty() { + return Ok(()); + } + let sql = format!( + "INSERT OR REPLACE INTO breach_alert_dismissals (breach_id, dismissed_at) VALUES {}", + sql_support::repeat_display(dismissals.len(), ",", |_, f| write!(f, "(?,?)")) + ); + let params: Vec<&dyn rusqlite::types::ToSql> = dismissals + .iter() + .flat_map(|d| -> [&dyn rusqlite::types::ToSql; 2] { [&d.breach_id, &d.dismissed_at] }) + .collect(); + conn.conn().execute(&sql, params.as_slice())?; + Ok(()) +} + +pub fn clear_breach_alert_dismissals(conn: &Connection, breach_ids: &[String]) -> Result<()> { + if breach_ids.is_empty() { + return Ok(()); + } + let sql = format!( + "DELETE FROM breach_alert_dismissals WHERE breach_id IN ({})", + sql_support::repeat_sql_vars(breach_ids.len()) + ); + conn.conn() + .execute(&sql, rusqlite::params_from_iter(breach_ids))?; + Ok(()) +} + +pub fn clear_all_breach_alert_dismissals(conn: &Connection) -> Result<()> { + conn.conn() + .execute("DELETE FROM breach_alert_dismissals", [])?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test::new_mem_db; + + #[test] + fn test_set_and_get() -> Result<()> { + let db = new_mem_db(); + let conn = db.get_connection()?; + + let dismissals = vec![ + BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 1000, + }, + BreachAlertDismissal { + breach_id: "breach-2".into(), + dismissed_at: 2000, + }, + ]; + set_breach_alert_dismissals(conn, &dismissals)?; + + let result = get_breach_alert_dismissals( + conn, + &["breach-1".into(), "breach-2".into(), "breach-3".into()], + )?; + assert_eq!(result.len(), 2); + assert!(result.contains(&BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 1000 + })); + assert!(result.contains(&BreachAlertDismissal { + breach_id: "breach-2".into(), + dismissed_at: 2000 + })); + Ok(()) + } + + #[test] + fn test_set_updates_existing() -> Result<()> { + let db = new_mem_db(); + let conn = db.get_connection()?; + + set_breach_alert_dismissals( + conn, + &[BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 1000, + }], + )?; + set_breach_alert_dismissals( + conn, + &[BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 2000, + }], + )?; + + let result = get_breach_alert_dismissals(conn, &["breach-1".into()])?; + assert_eq!( + result, + vec![BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 2000 + }] + ); + Ok(()) + } + + #[test] + fn test_clear_specific() -> Result<()> { + let db = new_mem_db(); + let conn = db.get_connection()?; + + set_breach_alert_dismissals( + conn, + &[ + BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 1000, + }, + BreachAlertDismissal { + breach_id: "breach-2".into(), + dismissed_at: 2000, + }, + ], + )?; + clear_breach_alert_dismissals(conn, &["breach-1".into()])?; + + let result = get_breach_alert_dismissals(conn, &["breach-1".into(), "breach-2".into()])?; + assert_eq!( + result, + vec![BreachAlertDismissal { + breach_id: "breach-2".into(), + dismissed_at: 2000 + }] + ); + Ok(()) + } + + #[test] + fn test_clear_all() -> Result<()> { + let db = new_mem_db(); + let conn = db.get_connection()?; + + set_breach_alert_dismissals( + conn, + &[ + BreachAlertDismissal { + breach_id: "breach-1".into(), + dismissed_at: 1000, + }, + BreachAlertDismissal { + breach_id: "breach-2".into(), + dismissed_at: 2000, + }, + ], + )?; + clear_all_breach_alert_dismissals(conn)?; + + let result = get_breach_alert_dismissals(conn, &["breach-1".into(), "breach-2".into()])?; + assert!(result.is_empty()); + Ok(()) + } + + #[test] + fn test_empty_inputs() -> Result<()> { + let db = new_mem_db(); + let conn = db.get_connection()?; + + assert_eq!(get_breach_alert_dismissals(conn, &[])?, vec![]); + set_breach_alert_dismissals(conn, &[])?; + clear_breach_alert_dismissals(conn, &[])?; + Ok(()) + } +} diff --git a/components/breach-alerts/src/db.rs b/components/breach-alerts/src/db.rs index a737817219..843e723f3e 100644 --- a/components/breach-alerts/src/db.rs +++ b/components/breach-alerts/src/db.rs @@ -49,11 +49,8 @@ impl BreachAlertsDb { | OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_READ_WRITE; - let conn = open_database_with_flags( - db_path, - flags, - &schema::BreachAlertsConnectionInitializer, - )?; + let conn = + open_database_with_flags(db_path, flags, &schema::BreachAlertsConnectionInitializer)?; Ok(Self { interrupt_handle: Arc::new(SqlInterruptHandle::new(&conn)), writer: BreachAlertsDbInner::Open(conn), @@ -64,7 +61,6 @@ impl BreachAlertsDb { Arc::clone(&self.interrupt_handle) } - #[allow(dead_code)] pub fn begin_interrupt_scope(&self) -> Result { Ok(self.interrupt_handle.begin_interrupt_scope()?) } @@ -106,13 +102,12 @@ impl ThreadSafeBreachAlertsDb { Arc::clone(&self.interrupt_handle) } - #[allow(dead_code)] pub fn begin_interrupt_scope(&self) -> Result { Ok(self.interrupt_handle.begin_interrupt_scope()?) } } -// Deref to a Mutex, which is how we will use ThreadSafeBreachAlertsDb most of the time +// Deref to a Mutex, which is how we will use ThreadSafeBreachAlertsDb most of the time impl Deref for ThreadSafeBreachAlertsDb { type Target = Mutex; @@ -176,25 +171,6 @@ fn unurl_path(p: impl AsRef) -> PathBuf { .unwrap_or_else(|| p.as_ref().to_owned()) } -/// If `p` is a file URL, return it, otherwise try and make it one. -/// -/// Errors if `p` is a relative non-url path, or if it's a URL path -/// that's isn't a `file:` URL. -#[allow(dead_code)] -pub fn ensure_url_path(p: impl AsRef) -> Result { - if let Some(u) = p.as_ref().to_str().and_then(|s| Url::parse(s).ok()) { - if u.scheme() == "file" { - Ok(u) - } else { - Err(Error::IllegalDatabasePath(p.as_ref().to_owned())) - } - } else { - let p = p.as_ref(); - let u = Url::from_file_path(p).map_err(|_| Error::IllegalDatabasePath(p.to_owned()))?; - Ok(u) - } -} - /// As best as possible, convert `p` into an absolute path, resolving /// all symlinks along the way. /// diff --git a/components/breach-alerts/src/error.rs b/components/breach-alerts/src/error.rs index 948f288039..1545a297f6 100644 --- a/components/breach-alerts/src/error.rs +++ b/components/breach-alerts/src/error.rs @@ -3,11 +3,18 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ pub use error_support::{debug, error, info, trace, warn}; +use error_support::{ErrorHandling, GetErrorHandling}; use interrupt_support::Interrupted; -pub type Result = std::result::Result; +/// Errors returned via the public (FFI) interface. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum BreachAlertsApiError { + #[error("Unexpected error: {reason}")] + Unexpected { reason: String }, +} +/// Errors used internally. #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Error executing SQL: {0}")] @@ -28,3 +35,19 @@ pub enum Error { #[error("The storage database has been closed")] DatabaseConnectionClosed, } + +/// Result for the public API. +pub type ApiResult = std::result::Result; + +/// Result for internal functions. +pub type Result = std::result::Result; + +impl GetErrorHandling for Error { + type ExternalError = BreachAlertsApiError; + + fn get_error_handling(&self) -> ErrorHandling { + ErrorHandling::convert(BreachAlertsApiError::Unexpected { + reason: self.to_string(), + }) + } +} diff --git a/components/breach-alerts/src/lib.rs b/components/breach-alerts/src/lib.rs index 86b8247de8..3f9d1479e3 100644 --- a/components/breach-alerts/src/lib.rs +++ b/components/breach-alerts/src/lib.rs @@ -5,9 +5,14 @@ #![allow(unknown_lints)] #![warn(rust_2018_idioms)] +mod api; pub mod db; pub mod error; mod schema; pub mod store; +pub use crate::api::BreachAlertDismissal; +pub use crate::error::{ApiResult, BreachAlertsApiError}; pub use crate::store::BreachAlertsStore; + +uniffi::setup_scaffolding!(); diff --git a/components/breach-alerts/src/store.rs b/components/breach-alerts/src/store.rs index 4e636d0745..6025d06860 100644 --- a/components/breach-alerts/src/store.rs +++ b/components/breach-alerts/src/store.rs @@ -2,8 +2,10 @@ * 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/. */ +use crate::api::{self, BreachAlertDismissal}; use crate::db::{BreachAlertsDb, ThreadSafeBreachAlertsDb}; use crate::error::*; +use error_support::handle_error; use std::path::Path; use std::sync::Arc; @@ -15,6 +17,7 @@ use interrupt_support::SqlInterruptHandle; /// /// An application should create only one store, and manage the instance /// as a singleton. +#[derive(uniffi::Object)] pub struct BreachAlertsStore { pub(crate) db: Arc, } @@ -42,9 +45,62 @@ impl BreachAlertsStore { pub fn interrupt_handle(&self) -> Arc { self.db.interrupt_handle() } +} + +#[uniffi::export] +impl BreachAlertsStore { + #[uniffi::constructor] + pub fn new_store(db_path: String) -> ApiResult { + Self::new(db_path).map_err(|e| BreachAlertsApiError::Unexpected { + reason: e.to_string(), + }) + } + + #[handle_error(Error)] + pub fn get_breach_alert_dismissals( + &self, + breach_ids: Vec, + ) -> ApiResult> { + let db = self.db.lock(); + let conn = db.get_connection()?; + api::get_breach_alert_dismissals(conn, &breach_ids) + } + + #[handle_error(Error)] + pub fn set_breach_alert_dismissals( + &self, + dismissals: Vec, + ) -> ApiResult<()> { + let db = self.db.lock(); + let conn = db.get_connection()?; + let tx = conn.unchecked_transaction()?; + api::set_breach_alert_dismissals(&tx, &dismissals)?; + tx.commit()?; + Ok(()) + } + + #[handle_error(Error)] + pub fn clear_breach_alert_dismissals(&self, breach_ids: Vec) -> ApiResult<()> { + let db = self.db.lock(); + let conn = db.get_connection()?; + let tx = conn.unchecked_transaction()?; + api::clear_breach_alert_dismissals(&tx, &breach_ids)?; + tx.commit()?; + Ok(()) + } + + #[handle_error(Error)] + pub fn clear_all_breach_alert_dismissals(&self) -> ApiResult<()> { + let db = self.db.lock(); + let conn = db.get_connection()?; + let tx = conn.unchecked_transaction()?; + api::clear_all_breach_alert_dismissals(&tx)?; + tx.commit()?; + Ok(()) + } - /// Closes the store and its database connection. - pub fn close(&self) -> Result<()> { + #[handle_error(Error)] + pub fn close(&self) -> ApiResult<()> { let mut db = self.db.lock(); db.close() }