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 13269b3f5c..a676c5ac60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -383,6 +383,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "breach-alerts" +version = "0.1.0" +dependencies = [ + "error-support", + "interrupt-support", + "parking_lot", + "rusqlite", + "sql-support", + "tempfile", + "thiserror 2.0.3", + "uniffi", + "url", +] + [[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/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 new file mode 100644 index 0000000000..5b98b45d0b --- /dev/null +++ b/components/breach-alerts/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "breach-alerts" +edition = "2021" +version = "0.1.0" +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"] } +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/README.md b/components/breach-alerts/README.md new file mode 100644 index 0000000000..a7b5e12b51 --- /dev/null +++ b/components/breach-alerts/README.md @@ -0,0 +1,10 @@ +# breach-alerts + +Stores and retrieves breach alert dismissals by breach ID. + +## 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 new file mode 100644 index 0000000000..b2140e922c --- /dev/null +++ b/components/breach-alerts/sql/create_schema.sql @@ -0,0 +1,14 @@ +-- 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/. + +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 +) WITHOUT ROWID; 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 new file mode 100644 index 0000000000..843e723f3e --- /dev/null +++ b/components/breach-alerts/src/db.rs @@ -0,0 +1,248 @@ +/* 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) + } + + 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) + } + + 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()) +} + +/// 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..1545a297f6 --- /dev/null +++ b/components/breach-alerts/src/error.rs @@ -0,0 +1,53 @@ +/* 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 error_support::{ErrorHandling, GetErrorHandling}; + +use interrupt_support::Interrupted; + +/// 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}")] + 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, +} + +/// 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 new file mode 100644 index 0000000000..3f9d1479e3 --- /dev/null +++ b/components/breach-alerts/src/lib.rs @@ -0,0 +1,18 @@ +/* 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/. */ + +#![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/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..6025d06860 --- /dev/null +++ b/components/breach-alerts/src/store.rs @@ -0,0 +1,118 @@ +/* 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::api::{self, BreachAlertDismissal}; +use crate::db::{BreachAlertsDb, ThreadSafeBreachAlertsDb}; +use crate::error::*; +use error_support::handle_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. +#[derive(uniffi::Object)] +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() + } +} + +#[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(()) + } + + #[handle_error(Error)] + pub fn close(&self) -> ApiResult<()> { + let mut db = self.db.lock(); + db.close() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_send() { + fn ensure_send() {} + ensure_send::(); + } +}