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    /// Rekey a keystore out-of-place: read every credential from the source
357    /// database and write it into a freshly created destination database.
358    ///
359    /// This is used to add, remove, or rotate the on-disk encryption key (a DEK
360    /// rotation): pass `dest_opts = Some(..)` to add or rotate encryption, or
361    /// `dest_opts = None` to write an unencrypted copy. `source_opts` must
362    /// supply the cipher/key the source was written with (or `None` if the
363    /// source is unencrypted).
364    ///
365    /// The operation is non-destructive to the source: the source database is
366    /// opened read-only-ish (no rows are mutated) and left fully intact, and the
367    /// destination is fully written before returning. Callers that own a
368    /// verify-then-swap-then-delete sequence (for example secret-vault
369    /// rotate-dek) should treat the returned destination as the new candidate
370    /// and only retire the source after independently verifying it.
371    ///
372    /// Each credential is copied with its `service`, `user`, `uuid`, `comment`,
373    /// and `secret` preserved. Whether the source enforced `(service, user)`
374    /// uniqueness is detected from the source schema and mirrored on the
375    /// destination so ambiguous keystores round-trip unchanged.
376    ///
377    /// `dest_path` must not already exist. Returns a [`RekeyOutcome`] describing
378    /// how many credentials were copied. No secret material is logged or
379    /// included in any returned value.
380    pub fn rekey(
381        source_path: impl AsRef<Path>,
382        source_opts: Option<EncryptionOpts>,
383        dest_path: impl AsRef<Path>,
384        dest_opts: Option<EncryptionOpts>,
385    ) -> Result<RekeyOutcome> {
386        let source_path = source_path.as_ref();
387        let dest_path = dest_path.as_ref();
388
389        if !source_path.is_file() {
390            return Err(Error::NoStorageAccess(Box::new(std::io::Error::new(
391                std::io::ErrorKind::NotFound,
392                format!("no source database at '{}'", source_path.display()),
393            ))));
394        }
395        if dest_path.exists() {
396            return Err(Error::Invalid(
397                "dest_path".to_string(),
398                format!("destination path '{}' already exists", dest_path.display()),
399            ));
400        }
401
402        let source = DbKeyStore::new(DbKeyStoreConfig {
403            path: source_path.to_path_buf(),
404            encryption_opts: source_opts,
405            // open permissively so we can read ambiguous keystores; uniqueness
406            // is detected from the schema below and mirrored on the destination.
407            allow_ambiguity: true,
408            ..Default::default()
409        })?;
410
411        let allow_ambiguity = source.detect_allow_ambiguity()?;
412
413        let dest = DbKeyStore::new(DbKeyStoreConfig {
414            path: dest_path.to_path_buf(),
415            encryption_opts: dest_opts,
416            allow_ambiguity,
417            ..Default::default()
418        })?;
419
420        let entries = source.read_all_for_rekey()?;
421        let copied = entries.len();
422        for entry in &entries {
423            dest.write_for_rekey(entry)?;
424        }
425
426        Ok(RekeyOutcome { copied })
427    }
428
429    /// Detect whether the source schema enforces `(service, user)` uniqueness.
430    /// Returns `true` if ambiguous entries are permitted (no unique constraint).
431    fn detect_allow_ambiguity(&self) -> Result<bool> {
432        let conn = self.inner.connect()?;
433        let has_unique = map_turso(block_on(schema_has_unique_service_user(&conn)))?;
434        Ok(!has_unique)
435    }
436
437    /// Read every credential (including secret) for an out-of-place rekey copy.
438    fn read_all_for_rekey(&self) -> Result<Vec<RekeyRecord>> {
439        let conn = self.inner.connect()?;
440        map_turso(block_on(query_all_for_rekey(&conn)))
441    }
442
443    /// Insert one credential into this (destination) store, preserving its
444    /// service, user, uuid, comment, and secret.
445    fn write_for_rekey(&self, record: &RekeyRecord) -> Result<()> {
446        validate_service_user(&record.service, &record.user)?;
447        validate_secret(&record.secret)?;
448        let credential = DbKeyCredential {
449            inner: Arc::clone(&self.inner),
450            id: CredId {
451                service: record.service.clone(),
452                user: record.user.clone(),
453            },
454            uuid: Some(normalize_uuid_input(&record.uuid)?),
455            comment: record.comment.clone(),
456        };
457        credential.set_secret(record.secret.as_slice())
458    }
459}
460
461/// Result of a [`DbKeyStore::rekey`] operation.
462///
463/// Intentionally carries no secret material so it is safe to log or format.
464#[derive(Debug, Clone, Copy, Eq, PartialEq)]
465pub struct RekeyOutcome {
466    /// Number of credentials copied from source to destination.
467    pub copied: usize,
468}
469
470/// One credential read from the source during rekey. The `secret` is held in a
471/// `Zeroizing` buffer so it is wiped from the heap when the record is dropped.
472struct RekeyRecord {
473    service: String,
474    user: String,
475    uuid: String,
476    comment: Option<String>,
477    secret: Zeroizing<Vec<u8>>,
478}
479
480impl fmt::Debug for RekeyRecord {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        // never expose the secret in Debug output
483        f.debug_struct("RekeyRecord")
484            .field("service", &self.service)
485            .field("user", &self.user)
486            .field("uuid", &self.uuid)
487            .field("comment", &self.comment)
488            .field("secret", &"<redacted>")
489            .finish()
490    }
491}
492
493impl std::fmt::Debug for DbKeyStore {
494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495        f.debug_struct("DbKeyStore")
496            .field("vendor", &self.vendor())
497            .field("id", &self.id())
498            .field("allow_ambiguity", &self.inner.allow_ambiguity)
499            .finish()
500    }
501}
502
503impl DbKeyStoreInner {
504    fn connect(&self) -> Result<Connection> {
505        let conn = map_turso(self.db.connect())?;
506        configure_connection(&conn)?;
507        Ok(conn)
508    }
509}
510
511impl DbKeyCredential {
512    async fn insert_credential(
513        &self,
514        conn: &Connection,
515        uuid: &str,
516        secret: Value,
517        comment: Value,
518    ) -> Result<()> {
519        conn.execute(
520            "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
521            (
522                self.id.service.as_str(),
523                self.id.user.as_str(),
524                uuid,
525                secret,
526                comment,
527            ),
528        )
529        .await
530        .map_err(map_turso_err)?;
531        Ok(())
532    }
533}
534
535impl CredentialStoreApi for DbKeyStore {
536    fn vendor(&self) -> String {
537        String::from("DbKeyStore, https://crates.io/crates/db-keystore")
538    }
539
540    fn id(&self) -> String {
541        self.inner.id.clone()
542    }
543
544    /// Create a credential entry for service and user.
545    /// Service and user must be non-empty, and within the length limits. (<=1024 chars)
546    /// Supported modifiers: `uuid`, `comment`.
547    fn build(
548        &self,
549        service: &str,
550        user: &str,
551        modifiers: Option<&HashMap<&str, &str>>,
552    ) -> Result<Entry> {
553        validate_service_user(service, user)?;
554        let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
555        let credential = DbKeyCredential {
556            inner: Arc::clone(&self.inner),
557            id: CredId {
558                service: service.to_string(),
559                user: user.to_string(),
560            },
561            uuid: mods
562                .get("uuid")
563                .map(|value| normalize_uuid_input(value))
564                .transpose()?,
565            comment: mods.get("comment").cloned(),
566        };
567        Ok(Entry::new_with_credential(Arc::new(credential)))
568    }
569
570    // Search based on regex criteria, returning a list of matching entries.
571    // include any of "service", "user", "uuid", or "comment" as (regex) search terms
572    // Notes:
573    // - uuids in the database are lowercase
574    // - If "comment" is an empty string, it matches entries with no comment.
575    //   To match on "any" comment, omit comment from the search spec.
576    fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
577        let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
578        let service_re = Regex::new(spec.get("service").map_or("", String::as_str))
579            .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
580        let user_re = Regex::new(spec.get("user").map_or("", String::as_str))
581            .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
582        let comment_re = Regex::new(spec.get("comment").map_or("", String::as_str))
583            .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
584        let uuid_spec = match spec.get("uuid") {
585            Some(value) => Some(normalize_uuid_input(value)?),
586            None => None,
587        };
588        let uuid_re = Regex::new(uuid_spec.as_deref().unwrap_or(""))
589            .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
590        let conn = self.inner.connect()?;
591        let rows = map_turso(block_on(query_all_credentials(&conn)))?;
592        let mut entries = Vec::new();
593        let comment_filter = spec.get("comment").cloned();
594        let filter_comment = spec.contains_key("comment");
595        let filter_comment_empty = comment_filter.as_deref().is_some_and(str::is_empty);
596        for (id, uuid, comment) in rows {
597            if !service_re.is_match(id.service.as_str()) {
598                continue;
599            }
600            if !user_re.is_match(id.user.as_str()) {
601                continue;
602            }
603            if !uuid_re.is_match(uuid.as_str()) {
604                continue;
605            }
606            if filter_comment {
607                if filter_comment_empty {
608                    // empty comment ("") matches only rows with no comment
609                    if comment.as_deref().is_some_and(|value| !value.is_empty()) {
610                        continue;
611                    }
612                } else {
613                    // non-empty comment must match
614                    match comment.as_ref() {
615                        Some(text) if comment_re.is_match(text.as_str()) => {}
616                        _ => continue,
617                    }
618                }
619            }
620            let credential = DbKeyCredential {
621                inner: Arc::clone(&self.inner),
622                id,
623                uuid: Some(uuid),
624                comment: None,
625            };
626            entries.push(Entry::new_with_credential(Arc::new(credential)));
627        }
628        Ok(entries)
629    }
630
631    fn as_any(&self) -> &dyn std::any::Any {
632        self
633    }
634
635    fn persistence(&self) -> CredentialPersistence {
636        CredentialPersistence::UntilDelete
637    }
638
639    fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
640        fmt::Debug::fmt(self, f)
641    }
642}
643
644impl DbKeyCredential {
645    fn get_secret_zeroizing(&self) -> Result<Zeroizing<Vec<u8>>> {
646        validate_service_user(&self.id.service, &self.id.user)?;
647        let conn = self.inner.connect()?;
648        if let Some(uuid) = &self.uuid {
649            let match_result = map_turso(block_on(fetch_secret_by_key(&conn, &self.id, uuid)))?;
650            match match_result {
651                LookupResult::None => Err(Error::NoEntry),
652                LookupResult::One(secret) => Ok(secret),
653                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
654                    &Arc::clone(&self.inner),
655                    &self.id,
656                    uuids,
657                ))),
658            }
659        } else {
660            let match_result = map_turso(block_on(fetch_secret_by_id(&conn, &self.id)))?;
661            match match_result {
662                LookupResult::None => Err(Error::NoEntry),
663                LookupResult::One(secret) => Ok(secret),
664                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
665                    &Arc::clone(&self.inner),
666                    &self.id,
667                    uuids,
668                ))),
669            }
670        }
671    }
672
673    async fn set_secret_unambiguous(
674        &self,
675        conn: &Connection,
676        make_secret_value: &dyn Fn() -> Value,
677        make_comment_value: &dyn Fn() -> Value,
678    ) -> Result<()> {
679        let uuid = new_uuid();
680        let _ = conn.execute(
681            "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
682            ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
683            (
684                self.id.service.as_str(),
685                self.id.user.as_str(),
686                uuid.as_str(),
687                make_secret_value(),
688                make_comment_value(),
689            ),
690        )
691        .await.map_err(map_turso_err)?;
692        Ok(())
693    }
694
695    async fn set_secret_with_uuid(
696        &self,
697        conn: &Connection,
698        uuid: &str,
699        make_secret_value: &dyn Fn() -> Value,
700        make_comment_value: &dyn Fn() -> Value,
701    ) -> Result<()> {
702        let updated = conn
703            .execute(
704                "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
705                (
706                    make_secret_value(),
707                    self.id.service.as_str(),
708                    self.id.user.as_str(),
709                    uuid,
710                ),
711            )
712            .await
713            .map_err(map_turso_err)?;
714        if updated > 0 {
715            return Ok(());
716        }
717        if !self.inner.allow_ambiguity {
718            let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
719            match uuids.len() {
720                0 => {}
721                1 => {
722                    if uuids[0] != uuid {
723                        return Err(Error::Invalid(
724                            "uuid".to_string(),
725                            "can't create ambiguous credential for service/user".to_string(),
726                        ));
727                    }
728                }
729                _ => {
730                    // if ambiguity is not allowed, unique index should have prevented this case
731                    return Err(Error::PlatformFailure(format!(
732                        "Database is in an invalid state: ambiguity not allowed, but multiple entries found for {:?}",
733                        self.id
734                    ).into()));
735                }
736            }
737        }
738        self.insert_credential(conn, uuid, make_secret_value(), make_comment_value())
739            .await?;
740        Ok(())
741    }
742
743    async fn set_secret_without_uuid(
744        &self,
745        conn: &Connection,
746        make_secret_value: &dyn Fn() -> Value,
747        make_comment_value: &dyn Fn() -> Value,
748    ) -> Result<()> {
749        let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
750        match uuids.len() {
751            0 => {
752                let uuid = new_uuid();
753                self.insert_credential(
754                    conn,
755                    uuid.as_str(),
756                    make_secret_value(),
757                    make_comment_value(),
758                )
759                .await?;
760                Ok(())
761            }
762            1 => {
763                conn.execute(
764                    "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
765                    (
766                        make_secret_value(),
767                        self.id.service.as_str(),
768                        self.id.user.as_str(),
769                        uuids[0].as_str(),
770                    ),
771                )
772                .await
773                .map_err(map_turso_err)?;
774                Ok(())
775            }
776            _ => Err(Error::Ambiguous(ambiguous_entries(
777                &self.inner,
778                &self.id,
779                uuids,
780            ))),
781        }
782    }
783
784    async fn set_secret_in_tx(
785        &self,
786        conn: &Connection,
787        make_secret_value: &dyn Fn() -> Value,
788        make_comment_value: &dyn Fn() -> Value,
789    ) -> Result<()> {
790        if let Some(uuid) = &self.uuid {
791            self.set_secret_with_uuid(conn, uuid.as_str(), make_secret_value, make_comment_value)
792                .await
793        } else {
794            self.set_secret_without_uuid(conn, make_secret_value, make_comment_value)
795                .await
796        }
797    }
798
799    async fn finish_tx(conn: &Connection, result: Result<()>) -> Result<()> {
800        match result {
801            Ok(()) => {
802                conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
803                Ok(())
804            }
805            Err(err) => {
806                if let Err(e2) = conn.execute("ROLLBACK", ()).await {
807                    log::error!(
808                        "While handling set_secret error ({err:?}). attempted ROLLBACK, which encountered secondary error: {e2:?}"
809                    );
810                }
811                Err(err)
812            }
813        }
814    }
815}
816
817impl CredentialApi for DbKeyCredential {
818    fn set_secret(&self, secret: &[u8]) -> Result<()> {
819        validate_service_user(&self.id.service, &self.id.user)?;
820        validate_secret(secret)?;
821        let make_secret_value = || Value::Blob(secret.to_vec());
822        let make_comment_value = || comment_value(self.comment.as_ref());
823        let conn = self.inner.connect()?;
824        if self.uuid.is_none() && !self.inner.allow_ambiguity {
825            return block_on(self.set_secret_unambiguous(
826                &conn,
827                &make_secret_value,
828                &make_comment_value,
829            ));
830        }
831        block_on(async {
832            conn.execute("BEGIN IMMEDIATE", ())
833                .await
834                .map_err(map_turso_err)?;
835            let result = self
836                .set_secret_in_tx(&conn, &make_secret_value, &make_comment_value)
837                .await;
838            Self::finish_tx(&conn, result).await
839        })
840    }
841
842    fn get_secret(&self) -> Result<Vec<u8>> {
843        let secret = self.get_secret_zeroizing()?;
844        Ok(take_zeroizing_vec(secret))
845    }
846
847    fn get_attributes(&self) -> Result<HashMap<String, String>> {
848        validate_service_user(&self.id.service, &self.id.user)?;
849        let conn = self.inner.connect()?;
850        if let Some(uuid) = &self.uuid {
851            let match_result = map_turso(block_on(fetch_comment_by_key(&conn, &self.id, uuid)))?;
852            match match_result {
853                LookupResult::None => Err(Error::NoEntry),
854                LookupResult::One(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
855                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
856                    &self.inner,
857                    &self.id,
858                    uuids,
859                ))),
860            }
861        } else {
862            let match_result = map_turso(block_on(fetch_comment_by_id(&conn, &self.id)))?;
863            match match_result {
864                LookupResult::None => Err(Error::NoEntry),
865                LookupResult::One(row) => Ok(attributes_for_uuid(row.uuid.as_str(), row.comment)),
866                LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
867                    &self.inner,
868                    &self.id,
869                    uuids,
870                ))),
871            }
872        }
873    }
874
875    fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
876        parse_attributes(&["comment"], Some(attrs))?;
877        let comment = attrs.get("comment").map(ToString::to_string);
878        let has_comment = attrs.contains_key("comment");
879        if !has_comment {
880            self.get_attributes()?;
881            return Ok(());
882        }
883        let comment = comment.filter(|value| !value.is_empty());
884        let make_comment_value = || comment_value(comment.as_ref());
885        let conn = self.inner.connect()?;
886        block_on(async {
887            conn.execute("BEGIN IMMEDIATE", ())
888                .await
889                .map_err(map_turso_err)?;
890            let result = match &self.uuid {
891                Some(uuid) => {
892                    let uuids = fetch_uuids_by_key(&conn, &self.id, uuid)
893                        .await
894                        .map_err(map_turso_err)?;
895                    match uuids.len() {
896                        0 => Err(Error::NoEntry),
897                        1 => {
898                            conn.execute(
899                                "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
900                                (
901                                    make_comment_value(),
902                                    self.id.service.as_str(),
903                                    self.id.user.as_str(),
904                                    uuid.as_str(),
905                                ),
906                            )
907                            .await
908                            .map_err(map_turso_err)?;
909                            Ok(())
910                        }
911                        _ => Err(Error::Ambiguous(ambiguous_entries(
912                            &self.inner,
913                            &self.id,
914                            uuids,
915                        ))),
916                    }
917                }
918                None if self.inner.allow_ambiguity => {
919                    let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
920                    match uuids.len() {
921                        0 => Err(Error::NoEntry),
922                        1 => {
923                            conn.execute(
924                                "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
925                                (
926                                    make_comment_value(),
927                                    self.id.service.as_str(),
928                                    self.id.user.as_str(),
929                                    uuids[0].as_str(),
930                                ),
931                            )
932                            .await
933                            .map_err(map_turso_err)?;
934                            Ok(())
935                        }
936                        _ => Err(Error::Ambiguous(ambiguous_entries(
937                            &self.inner,
938                            &self.id,
939                            uuids,
940                        ))),
941                    }
942                }
943                None => {
944                    let updated = conn
945                        .execute(
946                            "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
947                            (
948                                make_comment_value(),
949                                self.id.service.as_str(),
950                                self.id.user.as_str(),
951                            ),
952                        )
953                        .await
954                        .map_err(map_turso_err)?;
955                    if updated == 0 {
956                        Err(Error::NoEntry)
957                    } else {
958                        Ok(())
959                    }
960                }
961            };
962            match result {
963                Ok(()) => {
964                    conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
965                    Ok(())
966                }
967                Err(err) => {
968                    // attempt rollback but if rollback fails report original error
969                    let _ = conn.execute("ROLLBACK", ()).await;
970                    Err(err)
971                }
972            }
973        })
974    }
975
976    fn delete_credential(&self) -> Result<()> {
977        validate_service_user(&self.id.service, &self.id.user)?;
978        let conn = self.inner.connect()?;
979        if let Some(uuid) = &self.uuid {
980            let deleted = map_turso(block_on(conn.execute(
981                "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
982                (
983                    self.id.service.as_str(),
984                    self.id.user.as_str(),
985                    uuid.as_str(),
986                ),
987            )))?;
988            if deleted == 0 {
989                Err(Error::NoEntry)
990            } else {
991                Ok(())
992            }
993        } else {
994            let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
995            match uuids.len() {
996                0 => Err(Error::NoEntry),
997                1 => {
998                    map_turso(block_on(conn.execute(
999                        "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1000                        (
1001                            self.id.service.as_str(),
1002                            self.id.user.as_str(),
1003                            uuids[0].as_str(),
1004                        ),
1005                    )))?;
1006                    Ok(())
1007                }
1008                _ => Err(Error::Ambiguous(ambiguous_entries(
1009                    &self.inner,
1010                    &self.id,
1011                    uuids,
1012                ))),
1013            }
1014        }
1015    }
1016
1017    fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
1018        validate_service_user(&self.id.service, &self.id.user)?;
1019        let conn = self.inner.connect()?;
1020        if let Some(uuid) = &self.uuid {
1021            let uuids = map_turso(block_on(fetch_uuids_by_key(&conn, &self.id, uuid)))?;
1022            match uuids.len() {
1023                0 => Err(Error::NoEntry),
1024                1 => Ok(Some(Arc::new(DbKeyCredential {
1025                    inner: Arc::clone(&self.inner),
1026                    id: self.id.clone(),
1027                    uuid: Some(uuid.clone()),
1028                    comment: None,
1029                }))),
1030                _ => Err(Error::Ambiguous(ambiguous_entries(
1031                    &self.inner,
1032                    &self.id,
1033                    uuids,
1034                ))),
1035            }
1036        } else {
1037            let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
1038            match uuids.len() {
1039                0 => Err(Error::NoEntry),
1040                1 => Ok(Some(Arc::new(DbKeyCredential {
1041                    inner: Arc::clone(&self.inner),
1042                    id: self.id.clone(),
1043                    uuid: Some(uuids[0].clone()),
1044                    comment: None,
1045                }))),
1046                _ => Err(Error::Ambiguous(ambiguous_entries(
1047                    &self.inner,
1048                    &self.id,
1049                    uuids,
1050                ))),
1051            }
1052        }
1053    }
1054
1055    fn get_specifiers(&self) -> Option<(String, String)> {
1056        Some((self.id.service.clone(), self.id.user.clone()))
1057    }
1058
1059    fn as_any(&self) -> &dyn std::any::Any {
1060        self
1061    }
1062
1063    fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1064        fmt::Debug::fmt(self, f)
1065    }
1066}
1067
1068fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
1069    map_turso(block_on(conn.execute(
1070        "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
1071        (),
1072    )))?;
1073    map_turso(block_on(conn.execute(
1074        "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
1075        (),
1076    )))?;
1077    ensure_schema_version(conn)?;
1078    if !allow_ambiguity {
1079        // unique index used to help ensure non-ambiguity of (service,user)
1080        map_turso(block_on(conn.execute(
1081            "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
1082            (),
1083        )))?;
1084    } else if index_always {
1085        // Performance tradeoffs: this index roughly doubles the file size.
1086        // - For keystores with ~100 entries, it saves 0.1ms per lookup (.17 vs .28 ms).
1087        // - For ~1000 entries, the index saves ~1ms per lookup (0.1 vs 1.4 ms)
1088        // - Measured on a m3 macbook air.
1089        map_turso(block_on(conn.execute(
1090             "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
1091             (),
1092            )))?;
1093    }
1094    Ok(())
1095}
1096
1097fn ensure_schema_version(conn: &Connection) -> Result<()> {
1098    map_turso(block_on(async {
1099        let mut rows = conn
1100            .query(
1101                "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
1102                (),
1103            )
1104            .await?;
1105        if let Some(row) = rows.next().await? {
1106            let value = value_to_string(row.get_value(0)?, "schema_version")?;
1107            let version = value.parse::<u32>().map_err(|_| {
1108                turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
1109            })?;
1110            if version != SCHEMA_VERSION {
1111                return Err(turso::Error::ConversionFailure(format!(
1112                    "unsupported schema version: {version}"
1113                )));
1114            }
1115        } else {
1116            conn.execute(
1117                "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
1118                (SCHEMA_VERSION.to_string(),),
1119            )
1120            .await?;
1121        }
1122        Ok(())
1123    }))
1124}
1125
1126async fn query_all_credentials(
1127    conn: &Connection,
1128) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
1129    let mut rows = conn
1130        .query("SELECT service, user, uuid, comment FROM credentials", ())
1131        .await?;
1132    let mut results = Vec::new();
1133    while let Some(row) = rows.next().await? {
1134        let service = value_to_string(row.get_value(0)?, "service")?;
1135        let user = value_to_string(row.get_value(1)?, "user")?;
1136        let uuid = value_to_string(row.get_value(2)?, "uuid")?;
1137        let comment = value_to_option_string(row.get_value(3)?, "comment")?;
1138        results.push((CredId { service, user }, uuid, comment));
1139    }
1140    Ok(results)
1141}
1142
1143/// Read every credential, including its secret, for an out-of-place rekey copy.
1144async fn query_all_for_rekey(conn: &Connection) -> turso::Result<Vec<RekeyRecord>> {
1145    let mut rows = conn
1146        .query(
1147            "SELECT service, user, uuid, secret, comment FROM credentials",
1148            (),
1149        )
1150        .await?;
1151    let mut results = Vec::new();
1152    while let Some(row) = rows.next().await? {
1153        let service = value_to_string(row.get_value(0)?, "service")?;
1154        let user = value_to_string(row.get_value(1)?, "user")?;
1155        let uuid = value_to_string(row.get_value(2)?, "uuid")?;
1156        let secret = value_to_secret(row.get_value(3)?, "secret")?;
1157        let comment = value_to_option_string(row.get_value(4)?, "comment")?;
1158        results.push(RekeyRecord {
1159            service,
1160            user,
1161            uuid,
1162            comment,
1163            secret,
1164        });
1165    }
1166    Ok(results)
1167}
1168
1169/// Returns true if the `credentials` schema enforces `(service, user)`
1170/// uniqueness, either via a unique index or a table-level unique constraint.
1171async fn schema_has_unique_service_user(conn: &Connection) -> turso::Result<bool> {
1172    let mut rows = conn
1173        .query(
1174            "SELECT sql FROM sqlite_master \
1175             WHERE (type = 'index' AND tbl_name = 'credentials') \
1176                OR (type = 'table' AND name = 'credentials') \
1177             AND sql IS NOT NULL",
1178            (),
1179        )
1180        .await?;
1181    while let Some(row) = rows.next().await? {
1182        match row.get_value(0)? {
1183            Value::Text(sql) if is_unique_service_user_sql(sql.as_str()) => return Ok(true),
1184            _ => {}
1185        }
1186    }
1187    Ok(false)
1188}
1189
1190/// True if a `sqlite_master.sql` definition enforces uniqueness on
1191/// `(service, user)` (whitespace/quote/case insensitive).
1192fn is_unique_service_user_sql(sql: &str) -> bool {
1193    let normalized: String = sql
1194        .chars()
1195        .filter(|c| !c.is_whitespace() && *c != '"' && *c != '`')
1196        .flat_map(char::to_lowercase)
1197        .collect();
1198    normalized.contains("unique") && normalized.contains("(service,user)")
1199}
1200
1201async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
1202    let mut rows = conn
1203        .query(
1204            "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
1205            (id.service.as_str(), id.user.as_str()),
1206        )
1207        .await?;
1208    let mut uuids = Vec::new();
1209    while let Some(row) = rows.next().await? {
1210        let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1211        uuids.push(uuid);
1212    }
1213    Ok(uuids)
1214}
1215
1216async fn fetch_secret_by_key(
1217    conn: &Connection,
1218    id: &CredId,
1219    uuid: &str,
1220) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1221    let mut rows = conn
1222        .query(
1223            "SELECT secret FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1224            (id.service.as_str(), id.user.as_str(), uuid),
1225        )
1226        .await?;
1227    let mut secrets = Vec::new();
1228    while let Some(row) = rows.next().await? {
1229        let secret = value_to_secret(row.get_value(0)?, "secret")?;
1230        secrets.push(secret);
1231    }
1232    match secrets.len() {
1233        0 => Ok(LookupResult::None),
1234        1 => Ok(LookupResult::One(
1235            secrets.into_iter().next().expect("secret for single match"),
1236        )),
1237        _ => Ok(LookupResult::Ambiguous(vec![
1238            uuid.to_string();
1239            secrets.len()
1240        ])),
1241    }
1242}
1243
1244async fn fetch_comment_by_key(
1245    conn: &Connection,
1246    id: &CredId,
1247    uuid: &str,
1248) -> turso::Result<LookupResult<Option<String>>> {
1249    let mut rows = conn
1250        .query(
1251            "SELECT comment FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1252            (id.service.as_str(), id.user.as_str(), uuid),
1253        )
1254        .await?;
1255    let mut comments = Vec::new();
1256    while let Some(row) = rows.next().await? {
1257        let comment = value_to_option_string(row.get_value(0)?, "comment")?;
1258        comments.push(comment);
1259    }
1260    match comments.len() {
1261        0 => Ok(LookupResult::None),
1262        1 => Ok(LookupResult::One(
1263            comments
1264                .into_iter()
1265                .next()
1266                .expect("comment for single match"),
1267        )),
1268        _ => Ok(LookupResult::Ambiguous(vec![
1269            uuid.to_string();
1270            comments.len()
1271        ])),
1272    }
1273}
1274
1275async fn fetch_secret_by_id(
1276    conn: &Connection,
1277    id: &CredId,
1278) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1279    let uuids = fetch_uuids(conn, id).await?;
1280    match uuids.len() {
1281        0 => Ok(LookupResult::None),
1282        1 => fetch_secret_by_key(conn, id, uuids[0].as_str()).await,
1283        _ => Ok(LookupResult::Ambiguous(uuids)),
1284    }
1285}
1286
1287async fn fetch_comment_by_id(
1288    conn: &Connection,
1289    id: &CredId,
1290) -> turso::Result<LookupResult<CommentRow>> {
1291    let uuids = fetch_uuids(conn, id).await?;
1292    match uuids.len() {
1293        0 => Ok(LookupResult::None),
1294        1 => {
1295            let uuid = uuids.into_iter().next().expect("uuid");
1296            match fetch_comment_by_key(conn, id, uuid.as_str()).await? {
1297                LookupResult::None => Ok(LookupResult::None),
1298                LookupResult::One(comment) => Ok(LookupResult::One(CommentRow { uuid, comment })),
1299                LookupResult::Ambiguous(uuids) => Ok(LookupResult::Ambiguous(uuids)),
1300            }
1301        }
1302        _ => Ok(LookupResult::Ambiguous(uuids)),
1303    }
1304}
1305
1306async fn fetch_uuids_by_key(
1307    conn: &Connection,
1308    id: &CredId,
1309    uuid: &str,
1310) -> turso::Result<Vec<String>> {
1311    let mut rows = conn
1312        .query(
1313            "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1314            (id.service.as_str(), id.user.as_str(), uuid),
1315        )
1316        .await?;
1317    let mut uuids = Vec::new();
1318    while let Some(row) = rows.next().await? {
1319        let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1320        uuids.push(uuid);
1321    }
1322    Ok(uuids)
1323}
1324
1325fn ambiguous_entries(inner: &Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
1326    uuids
1327        .into_iter()
1328        .map(|uuid| {
1329            Entry::new_with_credential(Arc::new(DbKeyCredential {
1330                inner: Arc::clone(inner),
1331                id: id.clone(),
1332                uuid: Some(uuid),
1333                comment: None,
1334            }))
1335        })
1336        .collect()
1337}
1338
1339fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
1340    let mut attrs = HashMap::new();
1341    attrs.insert("uuid".to_string(), uuid.to_string());
1342    if let Some(comment) = comment {
1343        attrs.insert("comment".to_string(), comment);
1344    }
1345    attrs
1346}
1347
1348fn comment_value(comment: Option<&String>) -> Value {
1349    match comment {
1350        Some(value) if !value.is_empty() => Value::Text(value.clone()),
1351        _ => Value::Null,
1352    }
1353}
1354
1355fn normalize_uuid_input(value: &str) -> Result<String> {
1356    let lower = value.to_ascii_lowercase();
1357    let uuid = uuid::Uuid::try_parse(&lower)
1358        .map_err(|_| Error::Invalid("uuid".to_string(), "invalid uuid format".to_string()))?;
1359    if uuid.to_string() != lower {
1360        return Err(Error::Invalid(
1361            "uuid".to_string(),
1362            "invalid uuid format".to_string(),
1363        ));
1364    }
1365    Ok(lower)
1366}
1367
1368fn take_zeroizing_vec(mut value: Zeroizing<Vec<u8>>) -> Vec<u8> {
1369    std::mem::take(&mut *value)
1370}
1371
1372// Database configuration
1373// - Enable Write-Ahead Logging for better concurrency (less blocking)
1374// - Sets busy timeout - how long to wait when db is locked by another connection before returning error
1375fn configure_connection(conn: &Connection) -> Result<()> {
1376    map_turso(block_on(async {
1377        let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
1378        let _ = rows.next().await?;
1379        let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
1380        conn.execute(busy_stmt.as_str(), ()).await?;
1381        Ok(())
1382    }))
1383}
1384
1385/// Opens database. Retries with exponential backoff if the file is locked.
1386fn open_db_with_retry(
1387    path_str: &str,
1388    encryption_opts: Option<&EncryptionOptsZero>,
1389    vfs: Option<&str>,
1390) -> Result<Database> {
1391    let mut retries = OPEN_LOCK_RETRIES;
1392    let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1393    loop {
1394        let mut builder = Builder::new_local(path_str);
1395        if let Some(opts) = &encryption_opts {
1396            let turso_enc_opts = turso::EncryptionOpts {
1397                cipher: opts.cipher.clone(),
1398                // send not-zeroized key to turso each retry iteration
1399                hexkey: opts.hexkey.to_string(),
1400            };
1401            builder = builder
1402                .experimental_encryption(true)
1403                .with_encryption(turso_enc_opts);
1404        }
1405        if let Some(vfs) = vfs {
1406            builder = builder.with_io(vfs.to_string());
1407        }
1408        match block_on(builder.build()) {
1409            Ok(db) => return Ok(db),
1410            Err(err) => {
1411                check_decryption_error(&err)?;
1412                if retries == 0 || !is_turso_locking_error(&err) {
1413                    return Err(map_turso_err(err));
1414                }
1415                retries -= 1;
1416                let nanos = SystemTime::now()
1417                    .duration_since(UNIX_EPOCH)
1418                    .unwrap_or_default()
1419                    .subsec_nanos();
1420                let jitter = u64::from(nanos % 20);
1421                std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1422                backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1423            }
1424        }
1425    }
1426}
1427
1428fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
1429    let mut retries = OPEN_LOCK_RETRIES;
1430    let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1431    loop {
1432        match op() {
1433            Ok(value) => return Ok(value),
1434            Err(err) => {
1435                if retries == 0 || !is_turso_locking_error(&err) {
1436                    return Err(map_turso_err(err));
1437                }
1438                retries -= 1;
1439                let nanos = SystemTime::now()
1440                    .duration_since(UNIX_EPOCH)
1441                    .unwrap_or_default()
1442                    .subsec_nanos();
1443                let jitter = u64::from(nanos % 20);
1444                std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1445                backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1446            }
1447        }
1448    }
1449}
1450
1451fn is_turso_locking_error(err: &turso::Error) -> bool {
1452    let text = err.to_string().to_lowercase();
1453    text.contains("locking error")
1454        || text.contains("file is locked")
1455        || text.contains("database is locked")
1456        || text.contains("database is busy")
1457        || text.contains("sqlite_busy")
1458        || text.contains("sqlite_locked")
1459}
1460
1461fn check_decryption_error(err: &turso::Error) -> Result<()> {
1462    let text = err.to_string();
1463    if text.starts_with("Decryption failed") {
1464        return Err(keyring_core::Error::NoStorageAccess(Box::new(
1465            turso::Error::Error(format!("Invalid encryption key or cipher. {text}")),
1466        )));
1467    }
1468    Ok(())
1469}
1470
1471fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
1472    match value {
1473        Value::Text(text) => Ok(text),
1474        Value::Blob(blob) => String::from_utf8(blob)
1475            .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1476        other => Err(turso::Error::ConversionFailure(format!(
1477            "unexpected value for {field}: {other:?}"
1478        ))),
1479    }
1480}
1481
1482fn value_to_secret(value: Value, field: &str) -> turso::Result<Zeroizing<Vec<u8>>> {
1483    match value {
1484        Value::Blob(blob) => Ok(Zeroizing::new(blob)),
1485        Value::Text(text) => Ok(Zeroizing::new(text.into_bytes())),
1486        other => Err(turso::Error::ConversionFailure(format!(
1487            "unexpected value for {field}: {other:?}"
1488        ))),
1489    }
1490}
1491
1492fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1493    match value {
1494        Value::Null => Ok(None),
1495        Value::Text(text) => Ok(Some(text)),
1496        Value::Blob(blob) => String::from_utf8(blob)
1497            .map(Some)
1498            .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1499        other => Err(turso::Error::ConversionFailure(format!(
1500            "unexpected value for {field}: {other:?}"
1501        ))),
1502    }
1503}
1504
1505fn ensure_parent_dir(path: &Path) -> Result<()> {
1506    let parent = path
1507        .parent()
1508        .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1509    if parent.as_os_str().is_empty() {
1510        return Ok(());
1511    }
1512    std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1513}
1514
1515/// confirm service and user are non-empty and within length bounds
1516fn validate_service_user(service: &str, user: &str) -> Result<()> {
1517    if service.is_empty() {
1518        return Err(Error::Invalid(
1519            "service".to_string(),
1520            "service is empty".to_string(),
1521        ));
1522    }
1523    if user.is_empty() {
1524        return Err(Error::Invalid(
1525            "user".to_string(),
1526            "user is empty".to_string(),
1527        ));
1528    }
1529    if service.len() > MAX_NAME_LEN as usize {
1530        return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN));
1531    }
1532    if user.len() > MAX_NAME_LEN as usize {
1533        return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN));
1534    }
1535    Ok(())
1536}
1537
1538/// confirm secret is within length bounds
1539fn validate_secret(secret: &[u8]) -> Result<()> {
1540    if secret.len() > MAX_SECRET_LEN as usize {
1541        return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN));
1542    }
1543    Ok(())
1544}
1545
1546fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1547    result.map_err(map_turso_err)
1548}
1549
1550fn map_turso_err(err: turso::Error) -> Error {
1551    Error::PlatformFailure(Box::new(err))
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556    use super::*;
1557
1558    fn new_store(path: &Path) -> Arc<DbKeyStore> {
1559        let config = DbKeyStoreConfig {
1560            path: path.to_path_buf(),
1561            ..Default::default()
1562        };
1563        DbKeyStore::new(config).expect("failed to create store")
1564    }
1565
1566    fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1567        store
1568            .build(service, user, None)
1569            .expect("failed to build entry")
1570    }
1571
1572    fn set_password(entry: &Entry, password: &str) -> Result<()> {
1573        entry.set_password(password)
1574    }
1575
1576    fn set_secret(entry: &Entry, secret: &[u8]) -> Result<()> {
1577        entry.set_secret(secret)
1578    }
1579
1580    fn get_password(entry: &Entry) -> Result<Zeroizing<String>> {
1581        Ok(Zeroizing::new(entry.get_password()?))
1582    }
1583
1584    // test that non-existent parent dir is created on db open
1585    #[test]
1586    fn create_store_creates_parent_dir() {
1587        let dir = tempfile::tempdir().expect("tempdir");
1588        let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1589        let parent = db_path.parent().expect("parent");
1590        assert!(!parent.exists());
1591
1592        let config = DbKeyStoreConfig {
1593            path: db_path.clone(),
1594            ..Default::default()
1595        };
1596        let store = DbKeyStore::new(config).expect("create store");
1597        assert!(parent.is_dir());
1598
1599        let entry = build_entry(&store, "demo", "alice");
1600        set_password(&entry, "dromomeryx").expect("set_password");
1601    }
1602
1603    // test round-trip set and search
1604    #[test]
1605    fn set_password_then_search_finds_password() {
1606        let dir = tempfile::tempdir().expect("tempdir");
1607        let path = dir.path().join("keystore.db");
1608        let store = new_store(&path);
1609        let entry = build_entry(&store, "demo", "alice");
1610        set_password(&entry, "dromomeryx").expect("set_password");
1611
1612        let mut spec = HashMap::new();
1613        spec.insert("service", "demo");
1614        spec.insert("user", "alice");
1615        let results = store.search(&spec).expect("search");
1616        assert_eq!(results.len(), 1);
1617        let password = get_password(&results[0]).expect("get_password");
1618        assert_eq!(password.as_str(), "dromomeryx");
1619    }
1620
1621    // test with comment search
1622    #[test]
1623    fn comment_attributes_round_trip() {
1624        let dir = tempfile::tempdir().expect("tempdir");
1625        let path = dir.path().join("keystore.db");
1626        let store = new_store(&path);
1627        let entry = build_entry(&store, "demo", "alice");
1628        set_password(&entry, "dromomeryx").expect("set_password");
1629
1630        let update = HashMap::from([("comment", "note")]);
1631        entry.update_attributes(&update).expect("update_attributes");
1632        let attrs = entry.get_attributes().expect("get_attributes");
1633        assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1634        assert!(attrs.contains_key("uuid"));
1635
1636        let mut spec = HashMap::new();
1637        spec.insert("service", "demo");
1638        spec.insert("user", "alice");
1639        spec.insert("comment", "note");
1640        let results = store.search(&spec).expect("search");
1641        assert_eq!(results.len(), 1);
1642
1643        let uuid = attrs.get("uuid").cloned().expect("get uuid");
1644        let mut spec = HashMap::new();
1645        spec.insert("service", "demo");
1646        spec.insert("user", "alice");
1647        spec.insert("uuid", uuid.as_str());
1648        let results = store.search(&spec).expect("search");
1649        assert_eq!(results.len(), 1);
1650    }
1651
1652    #[test]
1653    fn comment_with_password_round_trip() {
1654        let dir = tempfile::tempdir().expect("tempdir");
1655        let path = dir.path().join("keystore.db");
1656        let store = new_store(&path);
1657        let entry = build_entry(&store, "demo", "alice");
1658        set_password(&entry, "dromomeryx").expect("set_password");
1659
1660        // set a comment attribute
1661        let update = HashMap::from([("comment", "note")]);
1662        entry.update_attributes(&update).expect("update_attributes");
1663
1664        // then search by comment
1665        let mut spec = HashMap::new();
1666        spec.insert("service", "demo");
1667        spec.insert("user", "alice");
1668        spec.insert("comment", "note");
1669        let results = store.search(&spec).expect("search");
1670        assert_eq!(results.len(), 1);
1671
1672        let found = &results[0];
1673        let password = get_password(found).expect("password with comment");
1674        assert_eq!(password.as_str(), "dromomeryx");
1675        let attrs = found.get_attributes().expect("get_attributes");
1676        assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1677        assert!(attrs.contains_key("uuid"));
1678    }
1679
1680    #[test]
1681    fn build_with_comment_modifier_sets_comment() -> Result<()> {
1682        let dir = tempfile::tempdir().expect("tempdir");
1683        let path = dir.path().join("keystore.db");
1684        let store = new_store(&path);
1685        let entry = store.build(
1686            "demo",
1687            "alice",
1688            Some(&HashMap::from([("comment", "initial")])),
1689        )?;
1690        set_password(&entry, "dromomeryx")?;
1691
1692        let attrs = entry.get_attributes()?;
1693        assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1694        Ok(())
1695    }
1696
1697    #[test]
1698    fn in_memory_store_round_trip() -> Result<()> {
1699        let config = DbKeyStoreConfig {
1700            vfs: Some("memory".to_string()),
1701            ..Default::default()
1702        };
1703        let store = DbKeyStore::new(config)?;
1704        let entry = build_entry(&store, "demo", "alice");
1705        set_password(&entry, "dromomeryx")?;
1706
1707        let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1708        assert_eq!(results.len(), 1);
1709        let password = get_password(&results[0])?;
1710        assert_eq!(password.as_str(), "dromomeryx");
1711        Ok(())
1712    }
1713
1714    // test that unique users in same service have unique keys
1715    #[test]
1716    fn stores_separate_service_user_pairs() -> Result<()> {
1717        let dir = tempfile::tempdir().expect("tempdir");
1718        let path = dir.path().join("keystore.db");
1719        let store = new_store(&path);
1720
1721        let entry = build_entry(&store, "myapp", "user1");
1722        set_password(&entry, "pw1")?;
1723        let entry = build_entry(&store, "myapp", "user2");
1724        set_password(&entry, "pw2")?;
1725        let entry = build_entry(&store, "myapp", "user3");
1726        set_password(&entry, "pw3")?;
1727
1728        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1729        assert_eq!(results.len(), 1);
1730        let password = get_password(&results[0])?;
1731        assert_eq!(password.as_str(), "pw1");
1732
1733        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1734        assert_eq!(results.len(), 1);
1735        let password = get_password(&results[0])?;
1736        assert_eq!(password.as_str(), "pw2");
1737
1738        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1739        assert_eq!(results.len(), 1);
1740        let password = get_password(&results[0])?;
1741        assert_eq!(password.as_str(), "pw3");
1742        Ok(())
1743    }
1744
1745    // search with regex
1746    #[test]
1747    fn search_regex() -> Result<()> {
1748        let dir = tempfile::tempdir().expect("tempdir");
1749        let path = dir.path().join("keystore.db");
1750        let store = new_store(&path);
1751
1752        let entry = build_entry(&store, "myapp", "user1");
1753        set_password(&entry, "pw1")?;
1754        let entry = build_entry(&store, "myapp", "user2");
1755        set_password(&entry, "pw2")?;
1756        let entry = build_entry(&store, "myapp", "user3");
1757        set_password(&entry, "pw3")?;
1758        let entry = build_entry(&store, "other-app", "user1");
1759        set_password(&entry, "pw4")?;
1760
1761        // regex search: all apps, user1
1762        let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1763        assert_eq!(results.len(), 2, "search *app, user1");
1764
1765        // regex search _or_
1766        let results = store.search(&HashMap::from([
1767            ("service", "myapp"),
1768            ("user", "user1|user2"),
1769        ]))?;
1770        assert_eq!(results.len(), 2, "search regex OR");
1771
1772        Ok(())
1773    }
1774
1775    // search with partial hashmap
1776    #[test]
1777    fn search_partial() -> Result<()> {
1778        let dir = tempfile::tempdir().expect("tempdir");
1779        let path = dir.path().join("keystore.db");
1780        let store = new_store(&path);
1781
1782        // empty db has no entries
1783        let results = store.search(&HashMap::new())?;
1784        assert_eq!(results.len(), 0, "empty db, no results");
1785
1786        let entry = build_entry(&store, "myapp", "user1");
1787        set_password(&entry, "pw1")?;
1788        let entry = build_entry(&store, "other-app", "user1");
1789        set_password(&entry, "pw2")?;
1790
1791        // empty search terms match all
1792        let results = store.search(&HashMap::new())?;
1793        assert_eq!(results.len(), 2, "search, empty hashmap");
1794
1795        // app-only match
1796        let results = store.search(&HashMap::from([("service", "myapp")]))?;
1797        assert_eq!(results.len(), 1, "search myapp");
1798
1799        // user-only match
1800        let results = store.search(&HashMap::from([("user", "user1")]))?;
1801        assert_eq!(results.len(), 2, "search user1");
1802        Ok(())
1803    }
1804
1805    // replacement
1806    #[test]
1807    fn repeated_set_replaces_secret() {
1808        let dir = tempfile::tempdir().expect("tempdir");
1809        let path = dir.path().join("keystore.db");
1810        let store = new_store(&path);
1811        let entry = build_entry(&store, "demo", "alice");
1812        set_password(&entry, "first").expect("password set 1");
1813        set_secret(&entry, b"second").expect("password set 2");
1814
1815        let mut spec = HashMap::new();
1816        spec.insert("service", "demo");
1817        spec.insert("user", "alice");
1818        let results = store.search(&spec).expect("search");
1819        assert_eq!(results.len(), 1);
1820        let password = get_password(&results[0]).expect("get first password");
1821        assert_eq!(
1822            password.as_str(),
1823            "second",
1824            "second password overwrites first"
1825        );
1826    }
1827
1828    #[test]
1829    fn same_service_user_entries_share_credential() -> Result<()> {
1830        let dir = tempfile::tempdir().expect("tempdir");
1831        let path = dir.path().join("keystore.db");
1832        let store = new_store(&path);
1833        let entry1 = build_entry(&store, "demo", "alice");
1834        let entry2 = build_entry(&store, "demo", "alice");
1835
1836        set_password(&entry1, "first")?;
1837        let password = get_password(&entry2)?;
1838        assert_eq!(password.as_str(), "first");
1839
1840        set_password(&entry2, "second")?;
1841        let password = get_password(&entry1)?;
1842        assert_eq!(password.as_str(), "second");
1843        Ok(())
1844    }
1845
1846    // deletion returns NoEntry if there is no matching entry
1847    #[test]
1848    fn remove_returns_no_entry() {
1849        let dir = tempfile::tempdir().expect("tempdir");
1850        let path = dir.path().join("keystore.db");
1851        let store = new_store(&path);
1852        let entry = build_entry(&store, "demo", "alice");
1853        set_password(&entry, "dromomeryx").expect("set password");
1854        entry.delete_credential().expect("delete credential");
1855        let err = entry.delete_credential().unwrap_err();
1856        assert!(matches!(err, Error::NoEntry));
1857    }
1858
1859    // deletion actually deletes
1860    #[test]
1861    fn remove_clears_secret() {
1862        let dir = tempfile::tempdir().expect("tempdir");
1863        let path = dir.path().join("keystore.db");
1864        let store = new_store(&path);
1865        let entry = build_entry(&store, "service", "user");
1866        set_password(&entry, "dromomeryx").expect("set password");
1867        entry.delete_credential().expect("delete credential");
1868
1869        let mut spec = HashMap::new();
1870        spec.insert("service", "demo");
1871        spec.insert("user", "alice");
1872        let results = store.search(&spec).expect("search");
1873        assert!(results.is_empty());
1874    }
1875
1876    #[test]
1877    fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1878        let dir = tempfile::tempdir().expect("tempdir");
1879        let path = dir.path().join("keystore.db");
1880        let config = DbKeyStoreConfig {
1881            path: path.clone(),
1882            allow_ambiguity: true,
1883            ..Default::default()
1884        };
1885        let store = DbKeyStore::new(config)?;
1886        let uuid1 = new_uuid();
1887        let uuid2 = new_uuid();
1888        let entry1 = store.build(
1889            "demo",
1890            "alice",
1891            Some(&HashMap::from([
1892                ("uuid", uuid1.as_str()),
1893                ("comment", "one"),
1894            ])),
1895        )?;
1896        let entry2 = store.build(
1897            "demo",
1898            "alice",
1899            Some(&HashMap::from([
1900                ("uuid", uuid2.as_str()),
1901                ("comment", "two"),
1902            ])),
1903        )?;
1904        set_password(&entry1, "first")?;
1905        set_password(&entry2, "second")?;
1906
1907        let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1908        assert_eq!(results.len(), 2);
1909
1910        let entry3 = build_entry(&store, "demo", "alice");
1911        let err = entry3.get_password().unwrap_err();
1912        assert!(matches!(err, Error::Ambiguous(_)));
1913        Ok(())
1914    }
1915
1916    #[test]
1917    fn duplicate_uuid_across_service_user_is_scoped() -> Result<()> {
1918        let dir = tempfile::tempdir().expect("tempdir");
1919        let path = dir.path().join("keystore.db");
1920        let config = DbKeyStoreConfig {
1921            path: path.clone(),
1922            allow_ambiguity: true,
1923            ..Default::default()
1924        };
1925        let store = DbKeyStore::new(config)?;
1926        let uuid = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
1927        let entry1 = store.build(
1928            "service-a",
1929            "user-a",
1930            Some(&HashMap::from([("uuid", uuid)])),
1931        )?;
1932        let entry2 = store.build(
1933            "service-b",
1934            "user-b",
1935            Some(&HashMap::from([("uuid", uuid)])),
1936        )?;
1937        set_password(&entry1, "pw1")?;
1938        set_password(&entry2, "pw2")?;
1939
1940        entry1.update_attributes(&HashMap::from([("comment", "note1")]))?;
1941        let attrs1 = entry1.get_attributes()?;
1942        assert_eq!(attrs1.get("comment"), Some(&"note1".to_string()));
1943
1944        let attrs2 = entry2.get_attributes()?;
1945        assert!(!attrs2.contains_key("comment"));
1946
1947        entry1.delete_credential()?;
1948        let pw2 = get_password(&entry2)?;
1949        assert_eq!(pw2.as_str(), "pw2");
1950        Ok(())
1951    }
1952
1953    #[test]
1954    fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1955        let dir = tempfile::tempdir().expect("tempdir");
1956        let path = dir.path().join("keystore.db");
1957        let store = new_store(&path);
1958        let uuid1 = new_uuid();
1959        let uuid2 = new_uuid();
1960        let entry1 = store.build(
1961            "demo",
1962            "alice",
1963            Some(&HashMap::from([("uuid", uuid1.as_str())])),
1964        )?;
1965        let entry2 = store.build(
1966            "demo",
1967            "alice",
1968            Some(&HashMap::from([("uuid", uuid2.as_str())])),
1969        )?;
1970
1971        set_password(&entry1, "first")?;
1972        let err = set_password(&entry2, "second").unwrap_err();
1973        assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1974        Ok(())
1975    }
1976
1977    #[test]
1978    fn impl_debug() -> Result<()> {
1979        let dir = tempfile::tempdir().expect("tempdir");
1980
1981        let path = dir.path().join("keystore1.db");
1982        let store = new_store(&path);
1983        eprintln!("basic: {store:?}");
1984
1985        let path = dir.path().join("keystore2.db");
1986        let config = DbKeyStoreConfig {
1987            path: path.clone(),
1988            encryption_opts: Some(EncryptionOpts::new(
1989                "aes256gcm",
1990                "0000000011111111222222223333333344444444555555556666666677777777",
1991            )),
1992            ..Default::default()
1993        };
1994        let store = DbKeyStore::new(config)?;
1995        eprintln!("with_enc: {store:?}");
1996
1997        let config = DbKeyStoreConfig {
1998            vfs: Some("memory".to_string()),
1999            ..Default::default()
2000        };
2001        let store = DbKeyStore::new(config)?;
2002        eprintln!("memory: {store:?}");
2003        Ok(())
2004    }
2005
2006    #[test]
2007    fn uuid_v7_strings_are_lexicographically_increasing() {
2008        let mut uuids = Vec::new();
2009        for _ in 0..8 {
2010            uuids.push(new_uuid());
2011        }
2012        for pair in uuids.windows(2) {
2013            assert!(
2014                pair[0] < pair[1],
2015                "uuid v7 strings should be lexicographically increasing"
2016            );
2017        }
2018    }
2019}