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