Skip to main content

db_keystore/
lib.rs

1//! File-backed credential store using Turso (sqlite) and optional encryption.
2//!
3//! This module implements the `keyring_core::api::CredentialStoreApi` and
4//! `keyring_core::api::CredentialApi` traits, so it can be used wherever a
5//! `keyring_core::api::CredentialStore` is expected (for example via
6//! `use_named_store_with_modifiers`).
7//!
8//! Features:
9//! - Local sqlite storage with optional encryption options.
10//! - WAL + busy timeout for better multi-process behavior.
11//! - Optional uniqueness enforcement on (service, user) via `allow_ambiguity=false`.
12//! - UUID and optional comment attributes exposed via the credential API.
13//! - Search supports `service`, `user`, `uuid`, and `comment` regex filters.
14//!
15//! Modifiers supported by `new_with_modifiers`:
16//! - `path` : path to the sqlite database file. Defaults to `$XDG_STATE_HOME/keystore.db` or `$HOME/.local/state/keystore.db`
17//! - `encryption-cipher` / `cipher`: encryption cipher name (optional, requires hexkey).
18//! - `encryption-hexkey` / `hexkey`: encryption key as hex (optional, requires cipher).
19//! - `allow-ambiguity` / `allow_ambiguity`: `"true"` or `"false"` (default `"false"`).
20//! - `vfs`: optional VFS backing selection (`"memory"`, `"io_uring"`, or `"syscall"`).
21//! - `index-always` / `index_always`: `"true"` or `"false"` (default `"false"`).
22//!
23//! Modifiers supported by `build`:
24//! - `uuid`: explicit credential UUID (allows creating ambiguous entries when allowed).
25//! - `comment`: initial comment value stored with the credential.
26//!
27//! Uuid are generated in v7 format <https://www.ietf.org/rfc/rfc9562.html#section-5.7>.
28//! Uuids generated by this crate will be unique (on a per-process basis), and sortable by time,
29//! so ambiguous entries can be sorted by date created, if desired. Uuids generated externally,
30//! and passed to `build()` are validated against the string syntax
31//! (e.g., `f81d4fae-7dec-11d0-a765-00a0c91e6bf6`), but are not checked for uniqueness or order.
32//!
33//!
34//! Example:
35//! ```rust
36//! use std::collections::HashMap;
37//! use db_keystore::{DbKeyStore, DbKeyStoreConfig};
38//!
39//! // create from config
40//! let config = DbKeyStoreConfig {
41//!     path: "keystore.db".into(),
42//!     ..Default::default()
43//! };
44//! let store = DbKeyStore::new(config).expect("store");
45//!
46//! // or, create with modifiers
47//! let modifiers = HashMap::from([
48//!     ("path", "keystore.db"),
49//!     ("allow-ambiguity", "true"),
50//! ]);
51//! let store = DbKeyStore::new_with_modifiers(&modifiers).expect("store");
52//! ```
53#![warn(clippy::pedantic)]
54#![allow(clippy::missing_errors_doc)]
55#![allow(clippy::must_use_candidate)]
56
57// SAFETY - Security and safety notes:
58//  - SQL injection: all user data is bound as parameters; SQL is static.
59//  - Secret handling: secrets are validated with length checks.
60//    Optional on-disk encryption implemented in database.
61//  - Concurrency: set_secret uses a transaction for read/modify/write; single statements
62//    are atomic in sqlite.
63//  - Contention: connections enable WAL and busy_timeout to reduce sqlite_BUSY in
64//    multi-process usage.
65//  - Uniqueness: allow_ambiguity=false enforces a unique (service,user) index and
66//    uses UPSERT; allow_ambiguity=true permits multiple credentials per pair.
67//  - Zeroize used to prevent secrets (db encryption keys and keyring secrets)
68//    leaking into heap from this crate.
69use std::{
70    collections::HashMap,
71    fmt,
72    path::{Path, PathBuf},
73    sync::Arc,
74    time::{SystemTime, UNIX_EPOCH},
75};
76
77use futures::executor::block_on;
78use keyring_core::{
79    api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
80    attributes::parse_attributes,
81    {Credential, Entry, Error, Result},
82};
83use regex::Regex;
84use turso::{Builder, Connection, Database, Value};
85use zeroize::Zeroizing;
86
87const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
88
89// length limits to prevent accidental blow up of db:
90//  - service and name: 1024 bytes
91//  - secret: 65536 bytes
92const MAX_NAME_LEN: u32 = 1024;
93const MAX_SECRET_LEN: u32 = 65536;
94const SCHEMA_VERSION: u32 = 1;
95// sqlite timeout for connection busy
96const BUSY_TIMEOUT_MS: u32 = 5000;
97/// retry logic for open and connect, in case there's a temporary file lock
98const OPEN_LOCK_RETRIES: u32 = 60;
99const OPEN_LOCK_BACKOFF_MS: u64 = 20;
100const OPEN_LOCK_BACKOFF_MAX_MS: u64 = 250;
101
102/// `EncryptionOpts` mirrors `turso::EncryptionOpts`
103/// See <https://docs.turso.tech/tursodb/encryption>
104/// Example ciphers: "aegis256", "aes256gcm". For 256-bit keys, hexkey is 64 chars.
105#[derive(Debug, Default, Clone)]
106pub struct EncryptionOpts {
107    pub cipher: String,
108    pub hexkey: String,
109}
110
111impl EncryptionOpts {
112    pub fn new(cipher: impl Into<String>, hexkey: impl Into<String>) -> Self {
113        Self {
114            cipher: cipher.into(),
115            hexkey: hexkey.into(),
116        }
117    }
118}
119
120// EncryptionOpts with zeroizing wrapper for the key. The external interface uses simply String,
121// which we immediately wrap in Zeroizing to ensure it never leaks into the heap. Memory safety
122// for the encryption key is maintained completely in this crate, until it is passed into turso.
123struct EncryptionOptsZero {
124    cipher: String,
125    hexkey: Zeroizing<String>,
126}
127
128impl From<EncryptionOpts> for EncryptionOptsZero {
129    fn from(value: EncryptionOpts) -> Self {
130        Self {
131            cipher: value.cipher,
132            hexkey: Zeroizing::new(value.hexkey),
133        }
134    }
135}
136
137/// Generates a new unique uuid as a string.
138/// v7 format: 48 bit timestamp in milliseconds and 78 bits of randomness
139/// `https://www.ietf.org/rfc/rfc9562.html#section-5.7`
140fn new_uuid() -> String {
141    uuid::Uuid::now_v7().to_string()
142}
143
144/// Configure turso database
145#[derive(Debug, Default, Clone)]
146pub struct DbKeyStoreConfig {
147    /// Path to database. Defaults to `$XDG_STATE_HOME/keystore.db` or `$HOME/.local/state/keystore.db`
148    pub path: PathBuf,
149
150    /// Set cipher and encryption key to enable encryption
151    pub encryption_opts: Option<EncryptionOpts>,
152
153    /// Allow non-unique values for (service,user) (see keyring-core documentation)
154    pub allow_ambiguity: bool,
155
156    /// Database I/O strategy: "`memory`", "`syscall`", or "`io_uring`"
157    ///  - "`memory`": In-memory database. Data is entirely in RAM, and data is lost when process exits. When vfs=memory, `path` and `encryption_opts` are ignored.
158    ///  - "`syscall`": Generic syscall backend. Uses standard POSIX system calls for file I/O. This is the most portable mode.
159    ///  - "`io_uring"`: Linux `io_uring` backend. Uses Linux's modern async I/O interface for better performance. Only available on Linux.
160    pub vfs: Option<String>,
161
162    /// Add index on (service,user) even when `allow_ambiguity` is true.
163    /// Increases file size about 2x, improves performance for large keystores (>~500 entries)
164    pub index_always: bool,
165}
166
167/// Default path for keystore: `$XDG_STATE_HOME/keystore.db` or `$HOME/.local/state/keystore.db`
168pub fn default_path() -> Result<PathBuf> {
169    Ok(match std::env::var("XDG_STATE_HOME") {
170        Ok(dir) => PathBuf::from(dir),
171        _ => match std::env::var("HOME") {
172            Ok(home) => PathBuf::from(home).join(".local").join("state"),
173            _ => {
174                return Err(Error::Invalid(
175                    "path".to_owned(),
176                    "No default path: set 'path' in Config (or modifiers), or define XDG_STATE_HOME or HOME"
177                        .to_owned(),
178                ));
179            }
180        },
181    }
182    .join("keystore.db"))
183}
184
185#[derive(Clone)]
186pub struct DbKeyStore {
187    inner: Arc<DbKeyStoreInner>,
188}
189
190#[derive(Debug)]
191struct DbKeyStoreInner {
192    db: Database,
193    id: String,
194    allow_ambiguity: bool,
195    encrypted: bool,
196    path: String,
197}
198
199#[derive(Debug, Clone, Eq, PartialEq, Hash)]
200struct CredId {
201    service: String,
202    user: String,
203}
204
205#[derive(Debug, Clone)]
206struct DbKeyCredential {
207    inner: Arc<DbKeyStoreInner>,
208    id: CredId,
209    uuid: Option<String>,
210    comment: Option<String>,
211}
212
213#[derive(Debug)]
214enum LookupResult<T> {
215    None,
216    One(T),
217    Ambiguous(Vec<String>),
218}
219
220#[derive(Debug)]
221struct CommentRow {
222    uuid: String,
223    comment: Option<String>,
224}
225
226impl DbKeyStore {
227    pub fn new(config: DbKeyStoreConfig) -> Result<Arc<DbKeyStore>> {
228        let start_time = SystemTime::now()
229            .duration_since(UNIX_EPOCH)
230            .unwrap_or_default()
231            .as_secs_f64();
232        // convert to zeroized before any possible error return
233        let zero_opts = config.encryption_opts.map(EncryptionOptsZero::from);
234        let (store, conn) = if let Some(vfs) = &config.vfs
235            && vfs == "memory"
236        {
237            // in-memory database. ignore path and encryption options
238            let db = map_turso(block_on(async {
239                Builder::new_local(":memory:")
240                    .with_io("memory".into())
241                    .build()
242                    .await
243            }))?;
244            let id = format!("DbKeyStore v{CRATE_VERSION} in-memory @ {start_time}",);
245            let conn = map_turso(db.connect())?;
246            (
247                DbKeyStore {
248                    inner: Arc::new(DbKeyStoreInner {
249                        db,
250                        id,
251                        allow_ambiguity: config.allow_ambiguity,
252                        encrypted: false,
253                        path: ":memory:".to_string(),
254                    }),
255                },
256                conn,
257            )
258        } else {
259            let path = if config.path.as_os_str().is_empty() {
260                default_path()?
261            } else {
262                config.path.clone()
263            };
264            // turso requires paths to be valid utf8
265            let path_str = path.to_str().ok_or_else(|| {
266                Error::Invalid("path".into(), "path must be valid UTF-8".to_string())
267            })?;
268            ensure_parent_dir(&path)?;
269            let encrypted = zero_opts.as_ref().is_some_and(|o| !o.cipher.is_empty());
270            let db = open_db_with_retry(path_str, zero_opts.as_ref(), config.vfs.as_deref())?;
271            let conn = retry_turso_locking(|| db.connect())?;
272            configure_connection(&conn)?;
273            let id = format!(
274                "DbKeyStore v{CRATE_VERSION} path:{path_str} enc:{encrypted} @ {start_time}",
275            );
276            (
277                DbKeyStore {
278                    inner: Arc::new(DbKeyStoreInner {
279                        db,
280                        id,
281                        allow_ambiguity: config.allow_ambiguity,
282                        encrypted,
283                        path: path_str.to_string(),
284                    }),
285                },
286                conn,
287            )
288        };
289        init_schema(&conn, config.allow_ambiguity, config.index_always)?;
290        Ok(Arc::new(store))
291    }
292
293    pub fn new_with_modifiers(modifiers: &HashMap<&str, &str>) -> Result<Arc<DbKeyStore>> {
294        // map is mutable so we can move hexkey into Zeroize and avoid dropping the String
295        let mut mods = parse_attributes(
296            &[
297                "path",
298                "encryption-cipher",
299                "cipher",
300                "encryption-hexkey",
301                "hexkey",
302                "*allow-ambiguity",
303                "*allow_ambiguity",
304                "vfs",
305                "*index-always",
306                "*index_always",
307            ],
308            Some(modifiers),
309        )?;
310        let path = mods.remove("path").map(PathBuf::from).unwrap_or_default();
311        let cipher = mods
312            .remove("encryption-cipher")
313            .or_else(|| mods.remove("cipher"));
314        let hexkey = mods
315            .remove("encryption-hexkey")
316            .or_else(|| mods.remove("hexkey"));
317        let allow_ambiguity = mods
318            .remove("allow-ambiguity")
319            .or_else(|| mods.remove("allow_ambiguity"))
320            .is_some_and(|value| value == "true");
321        let index_always = mods
322            .remove("index-always")
323            .or_else(|| mods.remove("index_always"))
324            .is_some_and(|value| value == "true");
325        let vfs = mods.remove("vfs");
326        let encryption_opts = match (cipher, hexkey) {
327            (None, None) => None,
328            (Some(cipher), Some(hexkey)) => Some(EncryptionOpts::new(cipher, hexkey)),
329            _ => {
330                return Err(Error::Invalid(
331                    "encryption".to_string(),
332                    "encryption-cipher and encryption-hexkey must both be set".to_string(),
333                ));
334            }
335        };
336        let config = DbKeyStoreConfig {
337            path,
338            encryption_opts,
339            allow_ambiguity,
340            vfs,
341            index_always,
342        };
343        DbKeyStore::new(config)
344    }
345
346    /// Returns true if the db file is encrypted
347    pub fn is_encrypted(&self) -> bool {
348        self.inner.encrypted
349    }
350
351    /// Returns path to database file
352    pub fn path(&self) -> String {
353        self.inner.path.clone()
354    }
355}
356
357impl std::fmt::Debug for DbKeyStore {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("DbKeyStore")
360            .field("vendor", &self.vendor())
361            .field("id", &self.id())
362            .field("allow_ambiguity", &self.inner.allow_ambiguity)
363            .finish()
364    }
365}
366
367impl DbKeyStoreInner {
368    fn connect(&self) -> Result<Connection> {
369        let conn = map_turso(self.db.connect())?;
370        configure_connection(&conn)?;
371        Ok(conn)
372    }
373}
374
375impl DbKeyCredential {
376    async fn insert_credential(
377        &self,
378        conn: &Connection,
379        uuid: &str,
380        secret: Value,
381        comment: Value,
382    ) -> Result<()> {
383        conn.execute(
384            "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
385            (
386                self.id.service.as_str(),
387                self.id.user.as_str(),
388                uuid,
389                secret,
390                comment,
391            ),
392        )
393        .await
394        .map_err(map_turso_err)?;
395        Ok(())
396    }
397}
398
399impl CredentialStoreApi for DbKeyStore {
400    fn vendor(&self) -> String {
401        String::from("DbKeyStore, https://crates.io/crates/db-keystore")
402    }
403
404    fn id(&self) -> String {
405        self.inner.id.clone()
406    }
407
408    /// Create a credential entry for service and user.
409    /// Service and user must be non-empty, and within the length limits. (<=1024 chars)
410    /// Supported modifiers: `uuid`, `comment`.
411    fn build(
412        &self,
413        service: &str,
414        user: &str,
415        modifiers: Option<&HashMap<&str, &str>>,
416    ) -> Result<Entry> {
417        validate_service_user(service, user)?;
418        let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
419        let credential = DbKeyCredential {
420            inner: Arc::clone(&self.inner),
421            id: CredId {
422                service: service.to_string(),
423                user: user.to_string(),
424            },
425            uuid: mods
426                .get("uuid")
427                .map(|value| normalize_uuid_input(value))
428                .transpose()?,
429            comment: mods.get("comment").cloned(),
430        };
431        Ok(Entry::new_with_credential(Arc::new(credential)))
432    }
433
434    // Search based on regex criteria, returning a list of matching entries.
435    // include any of "service", "user", "uuid", or "comment" as (regex) search terms
436    // Notes:
437    // - uuids in the database are lowercase
438    // - If "comment" is an empty string, it matches entries with no comment.
439    //   To match on "any" comment, omit comment from the search spec.
440    fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
441        let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
442        let service_re = Regex::new(spec.get("service").map_or("", String::as_str))
443            .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
444        let user_re = Regex::new(spec.get("user").map_or("", String::as_str))
445            .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
446        let comment_re = Regex::new(spec.get("comment").map_or("", String::as_str))
447            .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
448        let uuid_spec = match spec.get("uuid") {
449            Some(value) => Some(normalize_uuid_input(value)?),
450            None => None,
451        };
452        let uuid_re = Regex::new(uuid_spec.as_deref().unwrap_or(""))
453            .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
454        let conn = self.inner.connect()?;
455        let rows = map_turso(block_on(query_all_credentials(&conn)))?;
456        let mut entries = Vec::new();
457        let comment_filter = spec.get("comment").cloned();
458        let filter_comment = spec.contains_key("comment");
459        let filter_comment_empty = comment_filter.as_deref().is_some_and(str::is_empty);
460        for (id, uuid, comment) in rows {
461            if !service_re.is_match(id.service.as_str()) {
462                continue;
463            }
464            if !user_re.is_match(id.user.as_str()) {
465                continue;
466            }
467            if !uuid_re.is_match(uuid.as_str()) {
468                continue;
469            }
470            if filter_comment {
471                if filter_comment_empty {
472                    // empty comment ("") matches only rows with no comment
473                    if comment.as_deref().is_some_and(|value| !value.is_empty()) {
474                        continue;
475                    }
476                } else {
477                    // non-empty comment must match
478                    match comment.as_ref() {
479                        Some(text) if comment_re.is_match(text.as_str()) => {}
480                        _ => continue,
481                    }
482                }
483            }
484            let credential = DbKeyCredential {
485                inner: Arc::clone(&self.inner),
486                id,
487                uuid: Some(uuid),
488                comment: None,
489            };
490            entries.push(Entry::new_with_credential(Arc::new(credential)));
491        }
492        Ok(entries)
493    }
494
495    fn as_any(&self) -> &dyn std::any::Any {
496        self
497    }
498
499    fn persistence(&self) -> CredentialPersistence {
500        CredentialPersistence::UntilDelete
501    }
502
503    fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504        fmt::Debug::fmt(self, f)
505    }
506}
507
508impl DbKeyCredential {
509    fn get_secret_zeroizing(&self) -> Result<Zeroizing<Vec<u8>>> {
510        validate_service_user(&self.id.service, &self.id.user)?;
511        let conn = self.inner.connect()?;
512        if let Some(uuid) = &self.uuid {
513            let match_result = map_turso(block_on(fetch_secret_by_key(&conn, &self.id, uuid)))?;
514            match match_result {
515                LookupResult::None => Err(Error::NoEntry),
516                LookupResult::One(secret) => Ok(secret),
517                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
518                    &Arc::clone(&self.inner),
519                    &self.id,
520                    uuids,
521                ))),
522            }
523        } else {
524            let match_result = map_turso(block_on(fetch_secret_by_id(&conn, &self.id)))?;
525            match match_result {
526                LookupResult::None => Err(Error::NoEntry),
527                LookupResult::One(secret) => Ok(secret),
528                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
529                    &Arc::clone(&self.inner),
530                    &self.id,
531                    uuids,
532                ))),
533            }
534        }
535    }
536
537    async fn set_secret_unambiguous(
538        &self,
539        conn: &Connection,
540        make_secret_value: &dyn Fn() -> Value,
541        make_comment_value: &dyn Fn() -> Value,
542    ) -> Result<()> {
543        let uuid = new_uuid();
544        let _ = conn.execute(
545            "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
546            ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
547            (
548                self.id.service.as_str(),
549                self.id.user.as_str(),
550                uuid.as_str(),
551                make_secret_value(),
552                make_comment_value(),
553            ),
554        )
555        .await.map_err(map_turso_err)?;
556        Ok(())
557    }
558
559    async fn set_secret_with_uuid(
560        &self,
561        conn: &Connection,
562        uuid: &str,
563        make_secret_value: &dyn Fn() -> Value,
564        make_comment_value: &dyn Fn() -> Value,
565    ) -> Result<()> {
566        let updated = conn
567            .execute(
568                "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
569                (
570                    make_secret_value(),
571                    self.id.service.as_str(),
572                    self.id.user.as_str(),
573                    uuid,
574                ),
575            )
576            .await
577            .map_err(map_turso_err)?;
578        if updated > 0 {
579            return Ok(());
580        }
581        if !self.inner.allow_ambiguity {
582            let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
583            match uuids.len() {
584                0 => {}
585                1 => {
586                    if uuids[0] != uuid {
587                        return Err(Error::Invalid(
588                            "uuid".to_string(),
589                            "can't create ambiguous credential for service/user".to_string(),
590                        ));
591                    }
592                }
593                _ => {
594                    // if ambiguity is not allowed, unique index should have prevented this case
595                    return Err(Error::PlatformFailure(format!(
596                        "Database is in an invalid state: ambiguity not allowed, but multiple entries found for {:?}",
597                        &self.id
598                    ).into()));
599                }
600            }
601        }
602        self.insert_credential(conn, uuid, make_secret_value(), make_comment_value())
603            .await?;
604        Ok(())
605    }
606
607    async fn set_secret_without_uuid(
608        &self,
609        conn: &Connection,
610        make_secret_value: &dyn Fn() -> Value,
611        make_comment_value: &dyn Fn() -> Value,
612    ) -> Result<()> {
613        let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
614        match uuids.len() {
615            0 => {
616                let uuid = new_uuid();
617                self.insert_credential(
618                    conn,
619                    uuid.as_str(),
620                    make_secret_value(),
621                    make_comment_value(),
622                )
623                .await?;
624                Ok(())
625            }
626            1 => {
627                conn.execute(
628                    "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
629                    (
630                        make_secret_value(),
631                        self.id.service.as_str(),
632                        self.id.user.as_str(),
633                        uuids[0].as_str(),
634                    ),
635                )
636                .await
637                .map_err(map_turso_err)?;
638                Ok(())
639            }
640            _ => Err(Error::Ambiguous(ambiguous_entries(
641                &self.inner,
642                &self.id,
643                uuids,
644            ))),
645        }
646    }
647
648    async fn set_secret_in_tx(
649        &self,
650        conn: &Connection,
651        make_secret_value: &dyn Fn() -> Value,
652        make_comment_value: &dyn Fn() -> Value,
653    ) -> Result<()> {
654        if let Some(uuid) = &self.uuid {
655            self.set_secret_with_uuid(conn, uuid.as_str(), make_secret_value, make_comment_value)
656                .await
657        } else {
658            self.set_secret_without_uuid(conn, make_secret_value, make_comment_value)
659                .await
660        }
661    }
662
663    async fn finish_tx(conn: &Connection, result: Result<()>) -> Result<()> {
664        match result {
665            Ok(()) => {
666                conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
667                Ok(())
668            }
669            Err(err) => {
670                if let Err(e2) = conn.execute("ROLLBACK", ()).await {
671                    log::error!(
672                        "While handling set_secret error ({err:?}). attempted ROLLBACK, which encountered secondary error: {e2:?}"
673                    );
674                }
675                Err(err)
676            }
677        }
678    }
679}
680
681impl CredentialApi for DbKeyCredential {
682    fn set_secret(&self, secret: &[u8]) -> Result<()> {
683        validate_service_user(&self.id.service, &self.id.user)?;
684        validate_secret(secret)?;
685        let make_secret_value = || Value::Blob(secret.to_vec());
686        let make_comment_value = || comment_value(self.comment.as_ref());
687        let conn = self.inner.connect()?;
688        if self.uuid.is_none() && !self.inner.allow_ambiguity {
689            return block_on(self.set_secret_unambiguous(
690                &conn,
691                &make_secret_value,
692                &make_comment_value,
693            ));
694        }
695        block_on(async {
696            conn.execute("BEGIN IMMEDIATE", ())
697                .await
698                .map_err(map_turso_err)?;
699            let result = self
700                .set_secret_in_tx(&conn, &make_secret_value, &make_comment_value)
701                .await;
702            Self::finish_tx(&conn, result).await
703        })
704    }
705
706    fn get_secret(&self) -> Result<Vec<u8>> {
707        let secret = self.get_secret_zeroizing()?;
708        Ok(take_zeroizing_vec(secret))
709    }
710
711    fn get_attributes(&self) -> Result<HashMap<String, String>> {
712        validate_service_user(&self.id.service, &self.id.user)?;
713        let conn = self.inner.connect()?;
714        if let Some(uuid) = &self.uuid {
715            let match_result = map_turso(block_on(fetch_comment_by_key(&conn, &self.id, uuid)))?;
716            match match_result {
717                LookupResult::None => Err(Error::NoEntry),
718                LookupResult::One(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
719                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
720                    &self.inner,
721                    &self.id,
722                    uuids,
723                ))),
724            }
725        } else {
726            let match_result = map_turso(block_on(fetch_comment_by_id(&conn, &self.id)))?;
727            match match_result {
728                LookupResult::None => Err(Error::NoEntry),
729                LookupResult::One(row) => Ok(attributes_for_uuid(row.uuid.as_str(), row.comment)),
730                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
731                    &self.inner,
732                    &self.id,
733                    uuids,
734                ))),
735            }
736        }
737    }
738
739    fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
740        parse_attributes(&["comment"], Some(attrs))?;
741        let comment = attrs.get("comment").map(ToString::to_string);
742        let has_comment = attrs.contains_key("comment");
743        if !has_comment {
744            self.get_attributes()?;
745            return Ok(());
746        }
747        let comment = comment.and_then(|value| if value.is_empty() { None } else { Some(value) });
748        let make_comment_value = || comment_value(comment.as_ref());
749        let conn = self.inner.connect()?;
750        block_on(async {
751            conn.execute("BEGIN IMMEDIATE", ())
752                .await
753                .map_err(map_turso_err)?;
754            let result = match &self.uuid {
755                Some(uuid) => {
756                    let uuids = fetch_uuids_by_key(&conn, &self.id, uuid)
757                        .await
758                        .map_err(map_turso_err)?;
759                    match uuids.len() {
760                        0 => Err(Error::NoEntry),
761                        1 => {
762                            conn.execute(
763                                "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
764                                (
765                                    make_comment_value(),
766                                    self.id.service.as_str(),
767                                    self.id.user.as_str(),
768                                    uuid.as_str(),
769                                ),
770                            )
771                            .await
772                            .map_err(map_turso_err)?;
773                            Ok(())
774                        }
775                        _ => Err(Error::Ambiguous(ambiguous_entries(
776                            &self.inner,
777                            &self.id,
778                            uuids,
779                        ))),
780                    }
781                }
782                None if self.inner.allow_ambiguity => {
783                    let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
784                    match uuids.len() {
785                        0 => Err(Error::NoEntry),
786                        1 => {
787                            conn.execute(
788                                "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
789                                (
790                                    make_comment_value(),
791                                    self.id.service.as_str(),
792                                    self.id.user.as_str(),
793                                    uuids[0].as_str(),
794                                ),
795                            )
796                            .await
797                            .map_err(map_turso_err)?;
798                            Ok(())
799                        }
800                        _ => Err(Error::Ambiguous(ambiguous_entries(
801                            &self.inner,
802                            &self.id,
803                            uuids,
804                        ))),
805                    }
806                }
807                None => {
808                    let updated = conn
809                        .execute(
810                            "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
811                            (
812                                make_comment_value(),
813                                self.id.service.as_str(),
814                                self.id.user.as_str(),
815                            ),
816                        )
817                        .await
818                        .map_err(map_turso_err)?;
819                    if updated == 0 {
820                        Err(Error::NoEntry)
821                    } else {
822                        Ok(())
823                    }
824                }
825            };
826            match result {
827                Ok(()) => {
828                    conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
829                    Ok(())
830                }
831                Err(err) => {
832                    // attempt rollback but if rollback fails report original error
833                    let _ = conn.execute("ROLLBACK", ()).await;
834                    Err(err)
835                }
836            }
837        })
838    }
839
840    fn delete_credential(&self) -> Result<()> {
841        validate_service_user(&self.id.service, &self.id.user)?;
842        let conn = self.inner.connect()?;
843        if let Some(uuid) = &self.uuid {
844            let deleted = map_turso(block_on(conn.execute(
845                "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
846                (
847                    self.id.service.as_str(),
848                    self.id.user.as_str(),
849                    uuid.as_str(),
850                ),
851            )))?;
852            if deleted == 0 {
853                Err(Error::NoEntry)
854            } else {
855                Ok(())
856            }
857        } else {
858            let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
859            match uuids.len() {
860                0 => Err(Error::NoEntry),
861                1 => {
862                    map_turso(block_on(conn.execute(
863                        "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
864                        (
865                            self.id.service.as_str(),
866                            self.id.user.as_str(),
867                            uuids[0].as_str(),
868                        ),
869                    )))?;
870                    Ok(())
871                }
872                _ => Err(Error::Ambiguous(ambiguous_entries(
873                    &self.inner,
874                    &self.id,
875                    uuids,
876                ))),
877            }
878        }
879    }
880
881    fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
882        validate_service_user(&self.id.service, &self.id.user)?;
883        let conn = self.inner.connect()?;
884        if let Some(uuid) = &self.uuid {
885            let uuids = map_turso(block_on(fetch_uuids_by_key(&conn, &self.id, uuid)))?;
886            match uuids.len() {
887                0 => Err(Error::NoEntry),
888                1 => Ok(Some(Arc::new(DbKeyCredential {
889                    inner: Arc::clone(&self.inner),
890                    id: self.id.clone(),
891                    uuid: Some(uuid.clone()),
892                    comment: None,
893                }))),
894                _ => Err(Error::Ambiguous(ambiguous_entries(
895                    &self.inner,
896                    &self.id,
897                    uuids,
898                ))),
899            }
900        } else {
901            let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
902            match uuids.len() {
903                0 => Err(Error::NoEntry),
904                1 => Ok(Some(Arc::new(DbKeyCredential {
905                    inner: Arc::clone(&self.inner),
906                    id: self.id.clone(),
907                    uuid: Some(uuids[0].clone()),
908                    comment: None,
909                }))),
910                _ => Err(Error::Ambiguous(ambiguous_entries(
911                    &self.inner,
912                    &self.id,
913                    uuids,
914                ))),
915            }
916        }
917    }
918
919    fn get_specifiers(&self) -> Option<(String, String)> {
920        Some((self.id.service.clone(), self.id.user.clone()))
921    }
922
923    fn as_any(&self) -> &dyn std::any::Any {
924        self
925    }
926
927    fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
928        fmt::Debug::fmt(self, f)
929    }
930}
931
932fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
933    map_turso(block_on(conn.execute(
934        "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
935        (),
936    )))?;
937    map_turso(block_on(conn.execute(
938        "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
939        (),
940    )))?;
941    ensure_schema_version(conn)?;
942    if !allow_ambiguity {
943        // unique index used to help ensure non-ambiguity of (service,user)
944        map_turso(block_on(conn.execute(
945            "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
946            (),
947        )))?;
948    } else if index_always {
949        // Performance tradeoffs: this index roughly doubles the file size.
950        // - For keystores with ~100 entries, it saves 0.1ms per lookup (.17 vs .28 ms).
951        // - For ~1000 entries, the index saves ~1ms per lookup (0.1 vs 1.4 ms)
952        // - Measured on a m3 macbook air.
953        map_turso(block_on(conn.execute(
954             "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
955             (),
956            )))?;
957    }
958    Ok(())
959}
960
961fn ensure_schema_version(conn: &Connection) -> Result<()> {
962    map_turso(block_on(async {
963        let mut rows = conn
964            .query(
965                "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
966                (),
967            )
968            .await?;
969        if let Some(row) = rows.next().await? {
970            let value = value_to_string(row.get_value(0)?, "schema_version")?;
971            let version = value.parse::<u32>().map_err(|_| {
972                turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
973            })?;
974            if version != SCHEMA_VERSION {
975                return Err(turso::Error::ConversionFailure(format!(
976                    "unsupported schema version: {version}"
977                )));
978            }
979        } else {
980            conn.execute(
981                "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
982                (SCHEMA_VERSION.to_string(),),
983            )
984            .await?;
985        }
986        Ok(())
987    }))
988}
989
990async fn query_all_credentials(
991    conn: &Connection,
992) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
993    let mut rows = conn
994        .query("SELECT service, user, uuid, comment FROM credentials", ())
995        .await?;
996    let mut results = Vec::new();
997    while let Some(row) = rows.next().await? {
998        let service = value_to_string(row.get_value(0)?, "service")?;
999        let user = value_to_string(row.get_value(1)?, "user")?;
1000        let uuid = value_to_string(row.get_value(2)?, "uuid")?;
1001        let comment = value_to_option_string(row.get_value(3)?, "comment")?;
1002        results.push((CredId { service, user }, uuid, comment));
1003    }
1004    Ok(results)
1005}
1006
1007async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
1008    let mut rows = conn
1009        .query(
1010            "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
1011            (id.service.as_str(), id.user.as_str()),
1012        )
1013        .await?;
1014    let mut uuids = Vec::new();
1015    while let Some(row) = rows.next().await? {
1016        let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1017        uuids.push(uuid);
1018    }
1019    Ok(uuids)
1020}
1021
1022async fn fetch_secret_by_key(
1023    conn: &Connection,
1024    id: &CredId,
1025    uuid: &str,
1026) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1027    let mut rows = conn
1028        .query(
1029            "SELECT secret FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1030            (id.service.as_str(), id.user.as_str(), uuid),
1031        )
1032        .await?;
1033    let mut secrets = Vec::new();
1034    while let Some(row) = rows.next().await? {
1035        let secret = value_to_secret(row.get_value(0)?, "secret")?;
1036        secrets.push(secret);
1037    }
1038    match secrets.len() {
1039        0 => Ok(LookupResult::None),
1040        1 => Ok(LookupResult::One(
1041            secrets.into_iter().next().expect("secret for single match"),
1042        )),
1043        _ => Ok(LookupResult::Ambiguous(vec![
1044            uuid.to_string();
1045            secrets.len()
1046        ])),
1047    }
1048}
1049
1050async fn fetch_comment_by_key(
1051    conn: &Connection,
1052    id: &CredId,
1053    uuid: &str,
1054) -> turso::Result<LookupResult<Option<String>>> {
1055    let mut rows = conn
1056        .query(
1057            "SELECT comment FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1058            (id.service.as_str(), id.user.as_str(), uuid),
1059        )
1060        .await?;
1061    let mut comments = Vec::new();
1062    while let Some(row) = rows.next().await? {
1063        let comment = value_to_option_string(row.get_value(0)?, "comment")?;
1064        comments.push(comment);
1065    }
1066    match comments.len() {
1067        0 => Ok(LookupResult::None),
1068        1 => Ok(LookupResult::One(
1069            comments
1070                .into_iter()
1071                .next()
1072                .expect("comment for single match"),
1073        )),
1074        _ => Ok(LookupResult::Ambiguous(vec![
1075            uuid.to_string();
1076            comments.len()
1077        ])),
1078    }
1079}
1080
1081async fn fetch_secret_by_id(
1082    conn: &Connection,
1083    id: &CredId,
1084) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1085    let uuids = fetch_uuids(conn, id).await?;
1086    match uuids.len() {
1087        0 => Ok(LookupResult::None),
1088        1 => fetch_secret_by_key(conn, id, uuids[0].as_str()).await,
1089        _ => Ok(LookupResult::Ambiguous(uuids)),
1090    }
1091}
1092
1093async fn fetch_comment_by_id(
1094    conn: &Connection,
1095    id: &CredId,
1096) -> turso::Result<LookupResult<CommentRow>> {
1097    let uuids = fetch_uuids(conn, id).await?;
1098    match uuids.len() {
1099        0 => Ok(LookupResult::None),
1100        1 => {
1101            let uuid = uuids.into_iter().next().expect("uuid");
1102            match fetch_comment_by_key(conn, id, uuid.as_str()).await? {
1103                LookupResult::None => Ok(LookupResult::None),
1104                LookupResult::One(comment) => Ok(LookupResult::One(CommentRow { uuid, comment })),
1105                LookupResult::Ambiguous(uuids) => Ok(LookupResult::Ambiguous(uuids)),
1106            }
1107        }
1108        _ => Ok(LookupResult::Ambiguous(uuids)),
1109    }
1110}
1111
1112async fn fetch_uuids_by_key(
1113    conn: &Connection,
1114    id: &CredId,
1115    uuid: &str,
1116) -> turso::Result<Vec<String>> {
1117    let mut rows = conn
1118        .query(
1119            "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1120            (id.service.as_str(), id.user.as_str(), uuid),
1121        )
1122        .await?;
1123    let mut uuids = Vec::new();
1124    while let Some(row) = rows.next().await? {
1125        let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1126        uuids.push(uuid);
1127    }
1128    Ok(uuids)
1129}
1130
1131fn ambiguous_entries(inner: &Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
1132    uuids
1133        .into_iter()
1134        .map(|uuid| {
1135            Entry::new_with_credential(Arc::new(DbKeyCredential {
1136                inner: Arc::clone(inner),
1137                id: id.clone(),
1138                uuid: Some(uuid),
1139                comment: None,
1140            }))
1141        })
1142        .collect()
1143}
1144
1145fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
1146    let mut attrs = HashMap::new();
1147    attrs.insert("uuid".to_string(), uuid.to_string());
1148    if let Some(comment) = comment {
1149        attrs.insert("comment".to_string(), comment);
1150    }
1151    attrs
1152}
1153
1154fn comment_value(comment: Option<&String>) -> Value {
1155    match comment {
1156        Some(value) if !value.is_empty() => Value::Text(value.clone()),
1157        _ => Value::Null,
1158    }
1159}
1160
1161fn normalize_uuid_input(value: &str) -> Result<String> {
1162    let lower = value.to_ascii_lowercase();
1163    let uuid = uuid::Uuid::try_parse(&lower)
1164        .map_err(|_| Error::Invalid("uuid".to_string(), "invalid uuid format".to_string()))?;
1165    if uuid.to_string() != lower {
1166        return Err(Error::Invalid(
1167            "uuid".to_string(),
1168            "invalid uuid format".to_string(),
1169        ));
1170    }
1171    Ok(lower)
1172}
1173
1174fn take_zeroizing_vec(mut value: Zeroizing<Vec<u8>>) -> Vec<u8> {
1175    std::mem::take(&mut *value)
1176}
1177
1178// Database configuration
1179// - Enable Write-Ahead Logging for better concurrency (less blocking)
1180// - Sets busy timeout - how long to wait when db is locked by another connection before returning error
1181fn configure_connection(conn: &Connection) -> Result<()> {
1182    map_turso(block_on(async {
1183        let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
1184        let _ = rows.next().await?;
1185        let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
1186        conn.execute(busy_stmt.as_str(), ()).await?;
1187        Ok(())
1188    }))
1189}
1190
1191/// Opens database. Retries with exponential backoff if the file is locked.
1192fn open_db_with_retry(
1193    path_str: &str,
1194    encryption_opts: Option<&EncryptionOptsZero>,
1195    vfs: Option<&str>,
1196) -> Result<Database> {
1197    let mut retries = OPEN_LOCK_RETRIES;
1198    let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1199    loop {
1200        let mut builder = Builder::new_local(path_str);
1201        if let Some(opts) = &encryption_opts {
1202            let turso_enc_opts = turso::EncryptionOpts {
1203                cipher: opts.cipher.clone(),
1204                // send not-zeroized key to turso each retry iteration
1205                hexkey: opts.hexkey.to_string(),
1206            };
1207            builder = builder
1208                .experimental_encryption(true)
1209                .with_encryption(turso_enc_opts);
1210        }
1211        if let Some(vfs) = vfs {
1212            builder = builder.with_io(vfs.to_string());
1213        }
1214        match block_on(builder.build()) {
1215            Ok(db) => return Ok(db),
1216            Err(err) => {
1217                if retries == 0 || !is_turso_locking_error(&err) {
1218                    return Err(map_turso_err(err));
1219                }
1220                retries -= 1;
1221                let nanos = SystemTime::now()
1222                    .duration_since(UNIX_EPOCH)
1223                    .unwrap_or_default()
1224                    .subsec_nanos();
1225                let jitter = u64::from(nanos % 20);
1226                std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1227                backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1228            }
1229        }
1230    }
1231}
1232
1233fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
1234    let mut retries = OPEN_LOCK_RETRIES;
1235    let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1236    loop {
1237        match op() {
1238            Ok(value) => return Ok(value),
1239            Err(err) => {
1240                if retries == 0 || !is_turso_locking_error(&err) {
1241                    return Err(map_turso_err(err));
1242                }
1243                retries -= 1;
1244                let nanos = SystemTime::now()
1245                    .duration_since(UNIX_EPOCH)
1246                    .unwrap_or_default()
1247                    .subsec_nanos();
1248                let jitter = u64::from(nanos % 20);
1249                std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1250                backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1251            }
1252        }
1253    }
1254}
1255
1256fn is_turso_locking_error(err: &turso::Error) -> bool {
1257    let text = err.to_string().to_lowercase();
1258    text.contains("locking error")
1259        || text.contains("file is locked")
1260        || text.contains("database is locked")
1261        || text.contains("database is busy")
1262        || text.contains("sqlite_busy")
1263        || text.contains("sqlite_locked")
1264}
1265
1266fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
1267    match value {
1268        Value::Text(text) => Ok(text),
1269        Value::Blob(blob) => String::from_utf8(blob)
1270            .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1271        other => Err(turso::Error::ConversionFailure(format!(
1272            "unexpected value for {field}: {other:?}"
1273        ))),
1274    }
1275}
1276
1277fn value_to_secret(value: Value, field: &str) -> turso::Result<Zeroizing<Vec<u8>>> {
1278    match value {
1279        Value::Blob(blob) => Ok(Zeroizing::new(blob)),
1280        Value::Text(text) => Ok(Zeroizing::new(text.into_bytes())),
1281        other => Err(turso::Error::ConversionFailure(format!(
1282            "unexpected value for {field}: {other:?}"
1283        ))),
1284    }
1285}
1286
1287fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1288    match value {
1289        Value::Null => Ok(None),
1290        Value::Text(text) => Ok(Some(text)),
1291        Value::Blob(blob) => String::from_utf8(blob)
1292            .map(Some)
1293            .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1294        other => Err(turso::Error::ConversionFailure(format!(
1295            "unexpected value for {field}: {other:?}"
1296        ))),
1297    }
1298}
1299
1300fn ensure_parent_dir(path: &Path) -> Result<()> {
1301    let parent = path
1302        .parent()
1303        .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1304    if parent.as_os_str().is_empty() {
1305        return Ok(());
1306    }
1307    std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1308}
1309
1310/// confirm service and user are non-empty and within length bounds
1311fn validate_service_user(service: &str, user: &str) -> Result<()> {
1312    if service.is_empty() {
1313        return Err(Error::Invalid(
1314            "service".to_string(),
1315            "service is empty".to_string(),
1316        ));
1317    }
1318    if user.is_empty() {
1319        return Err(Error::Invalid(
1320            "user".to_string(),
1321            "user is empty".to_string(),
1322        ));
1323    }
1324    if service.len() > MAX_NAME_LEN as usize {
1325        return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN));
1326    }
1327    if user.len() > MAX_NAME_LEN as usize {
1328        return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN));
1329    }
1330    Ok(())
1331}
1332
1333/// confirm secret is within length bounds
1334fn validate_secret(secret: &[u8]) -> Result<()> {
1335    if secret.len() > MAX_SECRET_LEN as usize {
1336        return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN));
1337    }
1338    Ok(())
1339}
1340
1341fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1342    result.map_err(map_turso_err)
1343}
1344
1345fn map_turso_err(err: turso::Error) -> Error {
1346    Error::PlatformFailure(Box::new(err))
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352
1353    fn new_store(path: &Path) -> Arc<DbKeyStore> {
1354        let config = DbKeyStoreConfig {
1355            path: path.to_path_buf(),
1356            ..Default::default()
1357        };
1358        DbKeyStore::new(config).expect("failed to create store")
1359    }
1360
1361    fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1362        store
1363            .build(service, user, None)
1364            .expect("failed to build entry")
1365    }
1366
1367    fn set_password(entry: &Entry, password: &str) -> Result<()> {
1368        entry.set_password(password)
1369    }
1370
1371    fn set_secret(entry: &Entry, secret: &[u8]) -> Result<()> {
1372        entry.set_secret(secret)
1373    }
1374
1375    fn get_password(entry: &Entry) -> Result<Zeroizing<String>> {
1376        Ok(Zeroizing::new(entry.get_password()?))
1377    }
1378
1379    // test that non-existent parent dir is created on db open
1380    #[test]
1381    fn create_store_creates_parent_dir() {
1382        let dir = tempfile::tempdir().expect("tempdir");
1383        let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1384        let parent = db_path.parent().expect("parent");
1385        assert!(!parent.exists());
1386
1387        let config = DbKeyStoreConfig {
1388            path: db_path.clone(),
1389            ..Default::default()
1390        };
1391        let store = DbKeyStore::new(config).expect("create store");
1392        assert!(parent.is_dir());
1393
1394        let entry = build_entry(&store, "demo", "alice");
1395        set_password(&entry, "dromomeryx").expect("set_password");
1396    }
1397
1398    // test round-trip set and search
1399    #[test]
1400    fn set_password_then_search_finds_password() {
1401        let dir = tempfile::tempdir().expect("tempdir");
1402        let path = dir.path().join("keystore.db");
1403        let store = new_store(&path);
1404        let entry = build_entry(&store, "demo", "alice");
1405        set_password(&entry, "dromomeryx").expect("set_password");
1406
1407        let mut spec = HashMap::new();
1408        spec.insert("service", "demo");
1409        spec.insert("user", "alice");
1410        let results = store.search(&spec).expect("search");
1411        assert_eq!(results.len(), 1);
1412        let password = get_password(&results[0]).expect("get_password");
1413        assert_eq!(password.as_str(), "dromomeryx");
1414    }
1415
1416    // test with comment search
1417    #[test]
1418    fn comment_attributes_round_trip() {
1419        let dir = tempfile::tempdir().expect("tempdir");
1420        let path = dir.path().join("keystore.db");
1421        let store = new_store(&path);
1422        let entry = build_entry(&store, "demo", "alice");
1423        set_password(&entry, "dromomeryx").expect("set_password");
1424
1425        let update = HashMap::from([("comment", "note")]);
1426        entry.update_attributes(&update).expect("update_attributes");
1427        let attrs = entry.get_attributes().expect("get_attributes");
1428        assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1429        assert!(attrs.contains_key("uuid"));
1430
1431        let mut spec = HashMap::new();
1432        spec.insert("service", "demo");
1433        spec.insert("user", "alice");
1434        spec.insert("comment", "note");
1435        let results = store.search(&spec).expect("search");
1436        assert_eq!(results.len(), 1);
1437
1438        let uuid = attrs.get("uuid").cloned().expect("get uuid");
1439        let mut spec = HashMap::new();
1440        spec.insert("service", "demo");
1441        spec.insert("user", "alice");
1442        spec.insert("uuid", uuid.as_str());
1443        let results = store.search(&spec).expect("search");
1444        assert_eq!(results.len(), 1);
1445    }
1446
1447    #[test]
1448    fn comment_with_password_round_trip() {
1449        let dir = tempfile::tempdir().expect("tempdir");
1450        let path = dir.path().join("keystore.db");
1451        let store = new_store(&path);
1452        let entry = build_entry(&store, "demo", "alice");
1453        set_password(&entry, "dromomeryx").expect("set_password");
1454
1455        // set a comment attribute
1456        let update = HashMap::from([("comment", "note")]);
1457        entry.update_attributes(&update).expect("update_attributes");
1458
1459        // then search by comment
1460        let mut spec = HashMap::new();
1461        spec.insert("service", "demo");
1462        spec.insert("user", "alice");
1463        spec.insert("comment", "note");
1464        let results = store.search(&spec).expect("search");
1465        assert_eq!(results.len(), 1);
1466
1467        let found = &results[0];
1468        let password = get_password(found).expect("password with comment");
1469        assert_eq!(password.as_str(), "dromomeryx");
1470        let attrs = found.get_attributes().expect("get_attributes");
1471        assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1472        assert!(attrs.contains_key("uuid"));
1473    }
1474
1475    #[test]
1476    fn build_with_comment_modifier_sets_comment() -> Result<()> {
1477        let dir = tempfile::tempdir().expect("tempdir");
1478        let path = dir.path().join("keystore.db");
1479        let store = new_store(&path);
1480        let entry = store.build(
1481            "demo",
1482            "alice",
1483            Some(&HashMap::from([("comment", "initial")])),
1484        )?;
1485        set_password(&entry, "dromomeryx")?;
1486
1487        let attrs = entry.get_attributes()?;
1488        assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1489        Ok(())
1490    }
1491
1492    #[test]
1493    fn in_memory_store_round_trip() -> Result<()> {
1494        let config = DbKeyStoreConfig {
1495            vfs: Some("memory".to_string()),
1496            ..Default::default()
1497        };
1498        let store = DbKeyStore::new(config)?;
1499        let entry = build_entry(&store, "demo", "alice");
1500        set_password(&entry, "dromomeryx")?;
1501
1502        let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1503        assert_eq!(results.len(), 1);
1504        let password = get_password(&results[0])?;
1505        assert_eq!(password.as_str(), "dromomeryx");
1506        Ok(())
1507    }
1508
1509    // test that unique users in same service have unique keys
1510    #[test]
1511    fn stores_separate_service_user_pairs() -> Result<()> {
1512        let dir = tempfile::tempdir().expect("tempdir");
1513        let path = dir.path().join("keystore.db");
1514        let store = new_store(&path);
1515
1516        let entry = build_entry(&store, "myapp", "user1");
1517        set_password(&entry, "pw1")?;
1518        let entry = build_entry(&store, "myapp", "user2");
1519        set_password(&entry, "pw2")?;
1520        let entry = build_entry(&store, "myapp", "user3");
1521        set_password(&entry, "pw3")?;
1522
1523        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1524        assert_eq!(results.len(), 1);
1525        let password = get_password(&results[0])?;
1526        assert_eq!(password.as_str(), "pw1");
1527
1528        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1529        assert_eq!(results.len(), 1);
1530        let password = get_password(&results[0])?;
1531        assert_eq!(password.as_str(), "pw2");
1532
1533        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1534        assert_eq!(results.len(), 1);
1535        let password = get_password(&results[0])?;
1536        assert_eq!(password.as_str(), "pw3");
1537        Ok(())
1538    }
1539
1540    // search with regex
1541    #[test]
1542    fn search_regex() -> Result<()> {
1543        let dir = tempfile::tempdir().expect("tempdir");
1544        let path = dir.path().join("keystore.db");
1545        let store = new_store(&path);
1546
1547        let entry = build_entry(&store, "myapp", "user1");
1548        set_password(&entry, "pw1")?;
1549        let entry = build_entry(&store, "myapp", "user2");
1550        set_password(&entry, "pw2")?;
1551        let entry = build_entry(&store, "myapp", "user3");
1552        set_password(&entry, "pw3")?;
1553        let entry = build_entry(&store, "other-app", "user1");
1554        set_password(&entry, "pw4")?;
1555
1556        // regex search: all apps, user1
1557        let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1558        assert_eq!(results.len(), 2, "search *app, user1");
1559
1560        // regex search _or_
1561        let results = store.search(&HashMap::from([
1562            ("service", "myapp"),
1563            ("user", "user1|user2"),
1564        ]))?;
1565        assert_eq!(results.len(), 2, "search regex OR");
1566
1567        Ok(())
1568    }
1569
1570    // search with partial hashmap
1571    #[test]
1572    fn search_partial() -> Result<()> {
1573        let dir = tempfile::tempdir().expect("tempdir");
1574        let path = dir.path().join("keystore.db");
1575        let store = new_store(&path);
1576
1577        // empty db has no entries
1578        let results = store.search(&HashMap::new())?;
1579        assert_eq!(results.len(), 0, "empty db, no results");
1580
1581        let entry = build_entry(&store, "myapp", "user1");
1582        set_password(&entry, "pw1")?;
1583        let entry = build_entry(&store, "other-app", "user1");
1584        set_password(&entry, "pw2")?;
1585
1586        // empty search terms match all
1587        let results = store.search(&HashMap::new())?;
1588        assert_eq!(results.len(), 2, "search, empty hashmap");
1589
1590        // app-only match
1591        let results = store.search(&HashMap::from([("service", "myapp")]))?;
1592        assert_eq!(results.len(), 1, "search myapp");
1593
1594        // user-only match
1595        let results = store.search(&HashMap::from([("user", "user1")]))?;
1596        assert_eq!(results.len(), 2, "search user1");
1597        Ok(())
1598    }
1599
1600    // replacement
1601    #[test]
1602    fn repeated_set_replaces_secret() {
1603        let dir = tempfile::tempdir().expect("tempdir");
1604        let path = dir.path().join("keystore.db");
1605        let store = new_store(&path);
1606        let entry = build_entry(&store, "demo", "alice");
1607        set_password(&entry, "first").expect("password set 1");
1608        set_secret(&entry, b"second").expect("password set 2");
1609
1610        let mut spec = HashMap::new();
1611        spec.insert("service", "demo");
1612        spec.insert("user", "alice");
1613        let results = store.search(&spec).expect("search");
1614        assert_eq!(results.len(), 1);
1615        let password = get_password(&results[0]).expect("get first password");
1616        assert_eq!(
1617            password.as_str(),
1618            "second",
1619            "second password overwrites first"
1620        );
1621    }
1622
1623    #[test]
1624    fn same_service_user_entries_share_credential() -> Result<()> {
1625        let dir = tempfile::tempdir().expect("tempdir");
1626        let path = dir.path().join("keystore.db");
1627        let store = new_store(&path);
1628        let entry1 = build_entry(&store, "demo", "alice");
1629        let entry2 = build_entry(&store, "demo", "alice");
1630
1631        set_password(&entry1, "first")?;
1632        let password = get_password(&entry2)?;
1633        assert_eq!(password.as_str(), "first");
1634
1635        set_password(&entry2, "second")?;
1636        let password = get_password(&entry1)?;
1637        assert_eq!(password.as_str(), "second");
1638        Ok(())
1639    }
1640
1641    // deletion returns NoEntry if there is no matching entry
1642    #[test]
1643    fn remove_returns_no_entry() {
1644        let dir = tempfile::tempdir().expect("tempdir");
1645        let path = dir.path().join("keystore.db");
1646        let store = new_store(&path);
1647        let entry = build_entry(&store, "demo", "alice");
1648        set_password(&entry, "dromomeryx").expect("set password");
1649        entry.delete_credential().expect("delete credential");
1650        let err = entry.delete_credential().unwrap_err();
1651        assert!(matches!(err, Error::NoEntry));
1652    }
1653
1654    // deletion actually deletes
1655    #[test]
1656    fn remove_clears_secret() {
1657        let dir = tempfile::tempdir().expect("tempdir");
1658        let path = dir.path().join("keystore.db");
1659        let store = new_store(&path);
1660        let entry = build_entry(&store, "service", "user");
1661        set_password(&entry, "dromomeryx").expect("set password");
1662        entry.delete_credential().expect("delete credential");
1663
1664        let mut spec = HashMap::new();
1665        spec.insert("service", "demo");
1666        spec.insert("user", "alice");
1667        let results = store.search(&spec).expect("search");
1668        assert!(results.is_empty());
1669    }
1670
1671    #[test]
1672    fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1673        let dir = tempfile::tempdir().expect("tempdir");
1674        let path = dir.path().join("keystore.db");
1675        let config = DbKeyStoreConfig {
1676            path: path.clone(),
1677            allow_ambiguity: true,
1678            ..Default::default()
1679        };
1680        let store = DbKeyStore::new(config)?;
1681        let uuid1 = new_uuid();
1682        let uuid2 = new_uuid();
1683        let entry1 = store.build(
1684            "demo",
1685            "alice",
1686            Some(&HashMap::from([
1687                ("uuid", uuid1.as_str()),
1688                ("comment", "one"),
1689            ])),
1690        )?;
1691        let entry2 = store.build(
1692            "demo",
1693            "alice",
1694            Some(&HashMap::from([
1695                ("uuid", uuid2.as_str()),
1696                ("comment", "two"),
1697            ])),
1698        )?;
1699        set_password(&entry1, "first")?;
1700        set_password(&entry2, "second")?;
1701
1702        let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1703        assert_eq!(results.len(), 2);
1704
1705        let entry3 = build_entry(&store, "demo", "alice");
1706        let err = entry3.get_password().unwrap_err();
1707        assert!(matches!(err, Error::Ambiguous(_)));
1708        Ok(())
1709    }
1710
1711    #[test]
1712    fn duplicate_uuid_across_service_user_is_scoped() -> Result<()> {
1713        let dir = tempfile::tempdir().expect("tempdir");
1714        let path = dir.path().join("keystore.db");
1715        let config = DbKeyStoreConfig {
1716            path: path.clone(),
1717            allow_ambiguity: true,
1718            ..Default::default()
1719        };
1720        let store = DbKeyStore::new(config)?;
1721        let uuid = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
1722        let entry1 = store.build(
1723            "service-a",
1724            "user-a",
1725            Some(&HashMap::from([("uuid", uuid)])),
1726        )?;
1727        let entry2 = store.build(
1728            "service-b",
1729            "user-b",
1730            Some(&HashMap::from([("uuid", uuid)])),
1731        )?;
1732        set_password(&entry1, "pw1")?;
1733        set_password(&entry2, "pw2")?;
1734
1735        entry1.update_attributes(&HashMap::from([("comment", "note1")]))?;
1736        let attrs1 = entry1.get_attributes()?;
1737        assert_eq!(attrs1.get("comment"), Some(&"note1".to_string()));
1738
1739        let attrs2 = entry2.get_attributes()?;
1740        assert!(!attrs2.contains_key("comment"));
1741
1742        entry1.delete_credential()?;
1743        let pw2 = get_password(&entry2)?;
1744        assert_eq!(pw2.as_str(), "pw2");
1745        Ok(())
1746    }
1747
1748    #[test]
1749    fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1750        let dir = tempfile::tempdir().expect("tempdir");
1751        let path = dir.path().join("keystore.db");
1752        let store = new_store(&path);
1753        let uuid1 = new_uuid();
1754        let uuid2 = new_uuid();
1755        let entry1 = store.build(
1756            "demo",
1757            "alice",
1758            Some(&HashMap::from([("uuid", uuid1.as_str())])),
1759        )?;
1760        let entry2 = store.build(
1761            "demo",
1762            "alice",
1763            Some(&HashMap::from([("uuid", uuid2.as_str())])),
1764        )?;
1765
1766        set_password(&entry1, "first")?;
1767        let err = set_password(&entry2, "second").unwrap_err();
1768        assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1769        Ok(())
1770    }
1771
1772    #[test]
1773    fn impl_debug() -> Result<()> {
1774        let dir = tempfile::tempdir().expect("tempdir");
1775
1776        let path = dir.path().join("keystore1.db");
1777        let store = new_store(&path);
1778        eprintln!("basic: {store:?}");
1779
1780        let path = dir.path().join("keystore2.db");
1781        let config = DbKeyStoreConfig {
1782            path: path.clone(),
1783            encryption_opts: Some(EncryptionOpts::new(
1784                "aes256gcm",
1785                "0000000011111111222222223333333344444444555555556666666677777777",
1786            )),
1787            ..Default::default()
1788        };
1789        let store = DbKeyStore::new(config)?;
1790        eprintln!("with_enc: {store:?}");
1791
1792        let config = DbKeyStoreConfig {
1793            vfs: Some("memory".to_string()),
1794            ..Default::default()
1795        };
1796        let store = DbKeyStore::new(config)?;
1797        eprintln!("memory: {store:?}");
1798        Ok(())
1799    }
1800
1801    #[test]
1802    fn uuid_v7_strings_are_lexicographically_increasing() {
1803        let mut uuids = Vec::new();
1804        for _ in 0..8 {
1805            uuids.push(new_uuid());
1806        }
1807        for pair in uuids.windows(2) {
1808            assert!(
1809                pair[0] < pair[1],
1810                "uuid v7 strings should be lexicographically increasing"
1811            );
1812        }
1813    }
1814}