From 21e61439d797e932e05942b6101b17ad61c4399d Mon Sep 17 00:00:00 2001 From: Hinton Date: Tue, 5 Mar 2024 16:55:29 +0100 Subject: [PATCH] Explore using sqlite --- Cargo.lock | 95 +++++++++ crates/bitwarden/Cargo.toml | 1 + crates/bitwarden/src/error.rs | 2 + crates/bitwarden/src/vault/cipher/mod.rs | 1 + .../bitwarden/src/vault/cipher/repository.rs | 191 ++++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 crates/bitwarden/src/vault/cipher/repository.rs diff --git a/Cargo.lock b/Cargo.lock index 5c9d0fb58..953e16ec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -38,6 +50,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -344,6 +362,7 @@ dependencies = [ "rand", "rand_chacha", "reqwest", + "rusqlite", "rustls-platform-verifier", "schemars", "serde", @@ -1283,6 +1302,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1531,6 +1562,19 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" +dependencies = [ + "hashbrown 0.14.3", +] [[package]] name = "heck" @@ -1924,6 +1968,16 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "line-wrap" version = "0.1.1" @@ -2699,6 +2753,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.4.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "uuid", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -3794,6 +3863,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -4158,6 +4233,26 @@ dependencies = [ "url", ] +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.49", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/crates/bitwarden/Cargo.toml b/crates/bitwarden/Cargo.toml index 9950a788a..34de6dac5 100644 --- a/crates/bitwarden/Cargo.toml +++ b/crates/bitwarden/Cargo.toml @@ -46,6 +46,7 @@ rand = ">=0.8.5, <0.9" reqwest = { version = ">=0.11, <0.12", features = [ "json", ], default-features = false } +rusqlite = { version = ">=0.31.0, <0.32", features = ["uuid"] } schemars = { version = ">=0.8.9, <0.9", features = ["uuid1", "chrono"] } serde = { version = ">=1.0, <2.0", features = ["derive"] } serde_json = ">=1.0.96, <2.0" diff --git a/crates/bitwarden/src/error.rs b/crates/bitwarden/src/error.rs index ed5d27c3e..d9f31241f 100644 --- a/crates/bitwarden/src/error.rs +++ b/crates/bitwarden/src/error.rs @@ -41,6 +41,8 @@ pub enum Error { InvalidBase64(#[from] base64::DecodeError), #[error(transparent)] Chrono(#[from] chrono::ParseError), + #[error(transparent)] + Sqlite(#[from] rusqlite::Error), #[error("Received error message from server: [{}] {}", .status, .message)] ResponseContent { status: StatusCode, message: String }, diff --git a/crates/bitwarden/src/vault/cipher/mod.rs b/crates/bitwarden/src/vault/cipher/mod.rs index c2b49eb37..7fe6eb724 100644 --- a/crates/bitwarden/src/vault/cipher/mod.rs +++ b/crates/bitwarden/src/vault/cipher/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod identity; pub(crate) mod linked_id; pub(crate) mod local_data; pub(crate) mod login; +pub(crate) mod repository; pub(crate) mod secure_note; pub use attachment::{ diff --git a/crates/bitwarden/src/vault/cipher/repository.rs b/crates/bitwarden/src/vault/cipher/repository.rs new file mode 100644 index 000000000..7b80647a3 --- /dev/null +++ b/crates/bitwarden/src/vault/cipher/repository.rs @@ -0,0 +1,191 @@ +use rusqlite::{params, Connection}; +use uuid::Uuid; + +use crate::error::Error; + +use super::Cipher; + +struct CipherRow { + id: Uuid, + value: String, +} + +struct CipherSqliteRepository { + conn: Connection, +} + +impl CipherSqliteRepository { + pub fn new(conn: Connection) -> Self { + // TODO: Handle schema migrations + conn.execute( + "CREATE TABLE IF NOT EXISTS ciphers ( + id TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + (), + ) + .unwrap(); + + Self { conn } + } + + pub fn save(&self, cipher: &Cipher) -> Result<(), Error> { + let id = cipher.id.unwrap(); + let serialized = serde_json::to_string(cipher)?; + + let mut stmt = self.conn.prepare( + " + INSERT INTO ciphers (id, value) + VALUES (?1, ?2) + ON CONFLICT(id) DO UPDATE SET + value = ?2 + ", + )?; + stmt.execute((&id, &serialized))?; + + Ok(()) + } + + /// Replace all ciphers in the repository with the given ciphers. + /// + /// Typically used during a sync operation. + pub fn replace_all(&mut self, ciphers: &[&Cipher]) -> Result<(), Error> { + let tx = self.conn.transaction()?; + { + tx.execute("DELETE FROM ciphers", [])?; + + let mut stmt = tx.prepare( + " + INSERT INTO ciphers (id, value) + VALUES (?1, ?2) + ", + )?; + + for cipher in ciphers { + let id = cipher.id.unwrap(); + let serialized = serde_json::to_string(&cipher)?; + + stmt.execute(params![id, serialized])?; + } + } + tx.commit()?; + + Ok(()) + } + + pub fn delete_by_id(&self, id: Uuid) -> Result<(), Error> { + let mut stmt = self.conn.prepare("DELETE FROM ciphers WHERE id = ?1")?; + stmt.execute(params![id])?; + + Ok(()) + } + + pub fn get_all(&self) -> Result, Error> { + let mut stmt = self.conn.prepare("SELECT id, value FROM ciphers")?; + let rows = stmt.query_map([], |row| { + Ok(CipherRow { + id: row.get(0)?, + value: row.get(1)?, + }) + })?; + + let ciphers: Vec = rows + .flatten() + .flat_map(|row| -> Result { + let cipher: Cipher = serde_json::from_str(&row.value)?; + Ok(cipher) + }) + .collect(); + + Ok(ciphers) + } +} + +#[cfg(test)] +mod tests { + use crate::vault::{CipherRepromptType, CipherType}; + + use super::*; + use rusqlite::Connection; + + fn mock_cipher(id: Uuid) -> Cipher { + Cipher { + id: Some(id), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + notes: None, + r#type: CipherType::Login, + login: None, + identity: None, + card: None, + secure_note: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: false, + edit: true, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + deleted_date: None, + revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(), + } + } + + #[test] + fn test_save_get_all() { + let conn = Connection::open_in_memory().unwrap(); + let repo = CipherSqliteRepository::new(conn); + + let cipher = mock_cipher("d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap()); + + repo.save(&cipher).unwrap(); + + let ciphers = repo.get_all().unwrap(); + + assert_eq!(ciphers.len(), 1); + assert_eq!(ciphers[0].id, cipher.id); + } + + #[test] + fn test_delete_by_id() { + let conn = Connection::open_in_memory().unwrap(); + let repo = CipherSqliteRepository::new(conn); + + let cipher = mock_cipher("d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap()); + repo.save(&cipher).unwrap(); + + let ciphers = repo.get_all().unwrap(); + assert_eq!(ciphers.len(), 1); + + repo.delete_by_id(cipher.id.unwrap()).unwrap(); + let ciphers = repo.get_all().unwrap(); + assert_eq!(ciphers.len(), 0); + } + + #[test] + fn test_replace_all() { + let conn = Connection::open_in_memory().unwrap(); + let mut repo = CipherSqliteRepository::new(conn); + + let old_cipher = mock_cipher("d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap()); + + repo.save(&old_cipher).unwrap(); + + let ciphers = repo.get_all().unwrap(); + assert_eq!(ciphers.len(), 1); + assert_eq!(ciphers[0].id, old_cipher.id); + + let new_cipher = mock_cipher("d55d65d7-c161-40a4-94ca-b0d20184d91c".parse().unwrap()); + repo.replace_all(&[&new_cipher]).unwrap(); + + let ciphers = repo.get_all().unwrap(); + assert_eq!(ciphers.len(), 1); + assert_eq!(ciphers[0].id, new_cipher.id); + } +}