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//! Example:
28//! ```rust
29//! use std::collections::HashMap;
30//! use db_keystore::{DbKeyStore, DbKeyStoreConfig};
31//!
32//! // create from config
33//! let config = DbKeyStoreConfig {
34//!     path: "keystore.db".into(),
35//!     ..Default::default()
36//! };
37//! let store = DbKeyStore::new(&config).expect("store");
38//!
39//! // or, create with modifiers
40//! let modifiers = HashMap::from([
41//!     ("path", "keystore.db"),
42//!     ("allow-ambiguity", "true"),
43//! ]);
44//! let store = DbKeyStore::new_with_modifiers(&modifiers).expect("store");
45//! ```
46
47// SAFETY - Security and safety notes:
48//  - SQL injection: all user data is bound as parameters; SQL is static.
49//  - Secret handling: secrets are validated with length checks.
50//    Optional on-disk encryption implemented in database.
51//  - Concurrency: set_secret uses a transaction for read/modify/write; single statements
52//    are atomic in SQLite.
53//  - Contention: connections enable WAL and busy_timeout to reduce SQLITE_BUSY in
54//    multi-process usage.
55//  - Uniqueness: allow_ambiguity=false enforces a unique (service,user) index and
56//    uses UPSERT; allow_ambiguity=true permits multiple credentials per pair.
57
58use std::{
59    collections::HashMap,
60    fmt,
61    path::{Path, PathBuf},
62    sync::Arc,
63    time::{SystemTime, UNIX_EPOCH},
64};
65
66use futures::executor::block_on;
67use keyring_core::{
68    api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
69    attributes::parse_attributes,
70    {Credential, Entry, Error, Result},
71};
72use regex::Regex;
73use turso::{Builder, Connection, Database, Value};
74use uuid::Uuid;
75
76const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
77
78// length limits to prevent accidental blow up of db:
79//  - service and name: 1024 bytes
80//  - secret: 65536 bytes
81const MAX_NAME_LEN: usize = 1024;
82const MAX_SECRET_LEN: usize = 65536;
83const SCHEMA_VERSION: u32 = 1;
84// sqlite timeout for connection busy
85const BUSY_TIMEOUT_MS: u32 = 5000;
86/// retry logic for open and connect, in case there's a file lock
87const OPEN_LOCK_RETRIES: u32 = 60;
88const OPEN_LOCK_BACKOFF_MS: u64 = 20;
89const OPEN_LOCK_BACKOFF_MAX_MS: u64 = 250;
90
91/// EncryptionOpts mirrors turso::EncryptionOpts
92/// See https://docs.turso.tech/tursodb/encryption
93/// Example ciphers: "aegis256", "aes256gcm". For 256-bit keys, hexkey is 64 chars.
94#[derive(Debug, Default, Clone)]
95pub struct EncryptionOpts {
96    pub cipher: String,
97    pub hexkey: String,
98}
99
100/// Configure turso database
101#[derive(Debug, Default, Clone)]
102pub struct DbKeyStoreConfig {
103    /// Path to database. Defaults to $XDG_STATE_HOME/keystore.db or $HOME/.local/state/keystore.db
104    pub path: PathBuf,
105
106    /// Set cipher and encryption key to enable encryption
107    pub encryption_opts: Option<EncryptionOpts>,
108
109    /// Allow non-unique values for (service,user) (see keystore-core documentation)
110    pub allow_ambiguity: bool,
111
112    /// Database I/O strategy: "memory", "syscall", or "io_uring"
113    ///  - "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.
114    ///  - "syscall": Generic syscall backend. Uses standard POSIX system calls for file I/O. This is the most portable mode.
115    ///  - "io_uring": Linux io_uring backend. Uses Linux's modern async I/O interface for better performance. Only available on Linux.
116    pub vfs: Option<String>,
117
118    /// Add index on (service,user) even when allow_ambiguity is true.
119    /// Increases file size about 2x, improves performance for large keystores (>~500 entries)
120    pub index_always: bool,
121}
122
123/// Default path for keystore: $XDG_STATE_HOME/keystore.db or $HOME/.local/state/keystore.db
124pub fn default_path() -> Result<PathBuf> {
125    Ok(match std::env::var("XDG_STATE_HOME") {
126        Ok(d) => PathBuf::from(d),
127        _ => match std::env::var("HOME") {
128            Ok(h) => PathBuf::from(h).join(".local").join("state"),
129            _ => {
130                return Err(Error::Invalid(
131                    "path".to_string(),
132                    "No default path: set 'path' in Config (or modifiers), or define XDG_STATE_HOME or HOME"
133                        .to_string(),
134                ));
135            }
136        },
137    }
138    .join("keystore.db"))
139}
140
141#[derive(Clone)]
142pub struct DbKeyStore {
143    inner: Arc<DbKeyStoreInner>,
144}
145
146#[derive(Debug)]
147struct DbKeyStoreInner {
148    db: Database,
149    id: String,
150    allow_ambiguity: bool,
151    encrypted: bool,
152    path: String,
153}
154
155#[derive(Debug, Clone, Eq, PartialEq, Hash)]
156struct CredId {
157    service: String,
158    user: String,
159}
160
161#[derive(Debug, Clone)]
162struct DbKeyCredential {
163    inner: Arc<DbKeyStoreInner>,
164    id: CredId,
165    uuid: Option<String>,
166    comment: Option<String>,
167}
168
169impl DbKeyStore {
170    pub fn new(config: &DbKeyStoreConfig) -> Result<Arc<DbKeyStore>> {
171        let start_time = SystemTime::now()
172            .duration_since(UNIX_EPOCH)
173            .unwrap_or_default()
174            .as_secs_f64();
175        let (store, conn) = if let Some(vfs) = &config.vfs
176            && vfs == "memory"
177        {
178            // in-memory database. ignore path and encryption options
179            let db = map_turso(block_on(async {
180                Builder::new_local(":memory:")
181                    .with_io("memory".into())
182                    .build()
183                    .await
184            }))?;
185            let id = format!("DbKeyStore v{CRATE_VERSION} in-memory @ {start_time}",);
186            let conn = map_turso(db.connect())?;
187            (
188                DbKeyStore {
189                    inner: Arc::new(DbKeyStoreInner {
190                        db,
191                        id,
192                        allow_ambiguity: config.allow_ambiguity,
193                        encrypted: false,
194                        path: ":memory:".to_string(),
195                    }),
196                },
197                conn,
198            )
199        } else {
200            let path = if config.path.as_os_str().is_empty() {
201                default_path()?
202            } else {
203                config.path.clone()
204            };
205            ensure_parent_dir(&path)?;
206            let path_str = path.to_str().ok_or_else(|| {
207                Error::Invalid("path".into(), format!("invalid path {}", path.display()))
208            })?;
209            let db =
210                open_db_with_retry(path_str, config.encryption_opts.clone(), config.vfs.clone())?;
211            let conn = retry_turso_locking(|| db.connect())?;
212            configure_connection(&conn)?;
213            let encrypted = config
214                .encryption_opts
215                .as_ref()
216                .is_some_and(|o| !o.cipher.is_empty());
217            let id = format!(
218                "DbKeyStore v{CRATE_VERSION} path:{path_str} enc:{encrypted} @ {start_time}",
219            );
220            (
221                DbKeyStore {
222                    inner: Arc::new(DbKeyStoreInner {
223                        db,
224                        id,
225                        allow_ambiguity: config.allow_ambiguity,
226                        encrypted,
227                        path: path_str.to_string(),
228                    }),
229                },
230                conn,
231            )
232        };
233        init_schema(&conn, config.allow_ambiguity, config.index_always)?;
234        Ok(Arc::new(store))
235    }
236
237    pub fn new_with_modifiers(modifiers: &HashMap<&str, &str>) -> Result<Arc<DbKeyStore>> {
238        let mods = parse_attributes(
239            &[
240                "path",
241                "encryption-cipher",
242                "cipher",
243                "encryption-hexkey",
244                "hexkey",
245                "*allow-ambiguity",
246                "*allow_ambiguity",
247                "vfs",
248                "*index-always",
249                "*index_always",
250            ],
251            Some(modifiers),
252        )?;
253        let path = mods.get("path").map(PathBuf::from).unwrap_or_default();
254        let cipher = mods
255            .get("encryption-cipher")
256            .or_else(|| mods.get("cipher"))
257            .cloned();
258        let hexkey = mods
259            .get("encryption-hexkey")
260            .or_else(|| mods.get("hexkey"))
261            .cloned();
262        let allow_ambiguity = mods
263            .get("allow-ambiguity")
264            .or_else(|| mods.get("allow_ambiguity"))
265            .map(|value| value == "true")
266            .unwrap_or(false);
267        let index_always = mods
268            .get("index-always")
269            .or_else(|| mods.get("index_always"))
270            .map(|value| value == "true")
271            .unwrap_or(false);
272        let vfs = mods.get("vfs").cloned();
273        let encryption_opts = match (cipher, hexkey) {
274            (None, None) => None,
275            (Some(cipher), Some(hexkey)) => Some(EncryptionOpts { cipher, hexkey }),
276            _ => {
277                return Err(Error::Invalid(
278                    "encryption".to_string(),
279                    "encryption-cipher and encryption-hexkey must both be set".to_string(),
280                ));
281            }
282        };
283        let config = DbKeyStoreConfig {
284            path,
285            encryption_opts,
286            allow_ambiguity,
287            vfs,
288            index_always,
289        };
290        DbKeyStore::new(&config)
291    }
292
293    /// Returns true if the db file is encrypted
294    pub fn is_encrypted(&self) -> bool {
295        self.inner.encrypted
296    }
297
298    /// Returns path to database file
299    pub fn path(&self) -> String {
300        self.inner.path.clone()
301    }
302}
303
304impl std::fmt::Debug for DbKeyStore {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        f.debug_struct("DbKeyStore")
307            .field("id", &self.id())
308            .field("allow_ambiguity", &self.inner.allow_ambiguity)
309            .field("vendor", &self.vendor())
310            .finish()
311    }
312}
313
314impl DbKeyStoreInner {
315    fn connect(&self) -> Result<Connection> {
316        let conn = map_turso(self.db.connect())?;
317        configure_connection(&conn)?;
318        Ok(conn)
319    }
320}
321
322impl DbKeyCredential {
323    async fn insert_credential(
324        &self,
325        conn: &Connection,
326        uuid: &str,
327        secret: Value,
328        comment: Value,
329    ) -> Result<()> {
330        conn.execute(
331            "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
332            (
333                self.id.service.as_str(),
334                self.id.user.as_str(),
335                uuid,
336                secret,
337                comment,
338            ),
339        )
340        .await
341        .map_err(map_turso_err)?;
342        Ok(())
343    }
344}
345
346impl CredentialStoreApi for DbKeyStore {
347    fn vendor(&self) -> String {
348        String::from("DbKeyStore, https://crates.io/crates/db-keystore")
349    }
350
351    fn id(&self) -> String {
352        self.inner.id.clone()
353    }
354
355    /// Create a credential entry for service and user.
356    /// Service and user must be non-empty, and within the length limits. (<=1024 chars)
357    /// Supported modifiers: `uuid`, `comment`.
358    fn build(
359        &self,
360        service: &str,
361        user: &str,
362        modifiers: Option<&HashMap<&str, &str>>,
363    ) -> Result<Entry> {
364        validate_service_user(service, user)?;
365        let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
366        let credential = DbKeyCredential {
367            inner: Arc::clone(&self.inner),
368            id: CredId {
369                service: service.to_string(),
370                user: user.to_string(),
371            },
372            uuid: mods.get("uuid").cloned(),
373            comment: mods.get("comment").cloned(),
374        };
375        Ok(Entry::new_with_credential(Arc::new(credential)))
376    }
377
378    fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
379        let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
380        let service_re = Regex::new(spec.get("service").map(String::as_str).unwrap_or(""))
381            .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
382        let user_re = Regex::new(spec.get("user").map(String::as_str).unwrap_or(""))
383            .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
384        let comment_re = Regex::new(spec.get("comment").map(String::as_str).unwrap_or(""))
385            .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
386        let uuid_re = Regex::new(spec.get("uuid").map(String::as_str).unwrap_or(""))
387            .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
388        let conn = self.inner.connect()?;
389        let rows = map_turso(block_on(query_all_credentials(&conn)))?;
390        let mut entries = Vec::new();
391        let filter_comment = spec.contains_key("comment");
392        for (id, uuid, comment) in rows {
393            if !service_re.is_match(id.service.as_str()) {
394                continue;
395            }
396            if !user_re.is_match(id.user.as_str()) {
397                continue;
398            }
399            if !uuid_re.is_match(uuid.as_str()) {
400                continue;
401            }
402            if filter_comment {
403                match comment.as_ref() {
404                    Some(text) if comment_re.is_match(text.as_str()) => {}
405                    _ => continue,
406                }
407            }
408            let credential = DbKeyCredential {
409                inner: Arc::clone(&self.inner),
410                id,
411                uuid: Some(uuid),
412                comment: None,
413            };
414            entries.push(Entry::new_with_credential(Arc::new(credential)));
415        }
416        Ok(entries)
417    }
418
419    fn as_any(&self) -> &dyn std::any::Any {
420        self
421    }
422
423    fn persistence(&self) -> CredentialPersistence {
424        CredentialPersistence::UntilDelete
425    }
426
427    fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        fmt::Debug::fmt(self, f)
429    }
430}
431
432impl CredentialApi for DbKeyCredential {
433    fn set_secret(&self, secret: &[u8]) -> Result<()> {
434        validate_service_user(&self.id.service, &self.id.user)?;
435        validate_secret(secret)?;
436        let make_secret_value = || Value::Blob(secret.to_vec());
437        let make_comment_value = || match self.comment.as_ref() {
438            Some(value) => Value::Text(value.to_string()),
439            None => Value::Null,
440        };
441        let conn = self.inner.connect()?;
442        if self.uuid.is_none() && !self.inner.allow_ambiguity {
443            return map_turso(block_on(async {
444                let uuid = Uuid::new_v4().to_string();
445                conn.execute(
446                    "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
447                    ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
448                    (
449                        self.id.service.as_str(),
450                        self.id.user.as_str(),
451                        uuid.as_str(),
452                        make_secret_value(),
453                        make_comment_value(),
454                    ),
455                )
456                .await?;
457                Ok(())
458            }));
459        }
460        block_on(async {
461            conn.execute("BEGIN IMMEDIATE", ())
462                .await
463                .map_err(map_turso_err)?;
464            let result = match &self.uuid {
465                Some(uuid) => {
466                    let updated = conn
467                        .execute(
468                            "UPDATE credentials SET secret = ?1 WHERE uuid = ?2",
469                            (make_secret_value(), uuid.as_str()),
470                        )
471                        .await
472                        .map_err(map_turso_err)?;
473                    if updated > 0 {
474                        Ok(())
475                    } else {
476                        if !self.inner.allow_ambiguity {
477                            let uuids =
478                                fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
479                            match uuids.len() {
480                                0 => {}
481                                1 => {
482                                    if uuids[0] != *uuid {
483                                        return Err(Error::Invalid(
484                                            "uuid".to_string(),
485                                            "credential already exists for service/user"
486                                                .to_string(),
487                                        ));
488                                    }
489                                }
490                                _ => {
491                                    return Err(Error::Ambiguous(ambiguous_entries(
492                                        Arc::clone(&self.inner),
493                                        &self.id,
494                                        uuids,
495                                    )));
496                                }
497                            }
498                        }
499                        self.insert_credential(
500                            &conn,
501                            uuid.as_str(),
502                            make_secret_value(),
503                            make_comment_value(),
504                        )
505                        .await?;
506                        Ok(())
507                    }
508                }
509                None => {
510                    let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
511                    match uuids.len() {
512                        0 => {
513                            let uuid = Uuid::new_v4().to_string();
514                            self.insert_credential(
515                                &conn,
516                                uuid.as_str(),
517                                make_secret_value(),
518                                make_comment_value(),
519                            )
520                            .await?;
521                            Ok(())
522                        }
523                        1 => {
524                            conn.execute(
525                                "UPDATE credentials SET secret = ?1 WHERE uuid = ?2",
526                                (make_secret_value(), uuids[0].as_str()),
527                            )
528                            .await
529                            .map_err(map_turso_err)?;
530                            Ok(())
531                        }
532                        _ => Err(Error::Ambiguous(ambiguous_entries(
533                            Arc::clone(&self.inner),
534                            &self.id,
535                            uuids,
536                        ))),
537                    }
538                }
539            };
540            match result {
541                Ok(()) => {
542                    conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
543                    Ok(())
544                }
545                Err(err) => {
546                    let _ = conn.execute("ROLLBACK", ()).await;
547                    Err(err)
548                }
549            }
550        })
551    }
552
553    fn get_secret(&self) -> Result<Vec<u8>> {
554        validate_service_user(&self.id.service, &self.id.user)?;
555        let conn = self.inner.connect()?;
556        match &self.uuid {
557            Some(uuid) => {
558                let credential = map_turso(block_on(fetch_credential_by_uuid(&conn, uuid)))?;
559                match credential {
560                    Some((secret, _comment)) => Ok(secret),
561                    None => Err(Error::NoEntry),
562                }
563            }
564            None => {
565                let matches = map_turso(block_on(fetch_credentials_by_id(&conn, &self.id)))?;
566                match matches.len() {
567                    0 => Err(Error::NoEntry),
568                    1 => Ok(matches[0].1.clone()),
569                    _ => Err(Error::Ambiguous(ambiguous_entries(
570                        Arc::clone(&self.inner),
571                        &self.id,
572                        matches.into_iter().map(|pair| pair.0).collect(),
573                    ))),
574                }
575            }
576        }
577    }
578
579    fn get_attributes(&self) -> Result<HashMap<String, String>> {
580        validate_service_user(&self.id.service, &self.id.user)?;
581        let conn = self.inner.connect()?;
582        match &self.uuid {
583            Some(uuid) => {
584                let credential = map_turso(block_on(fetch_credential_by_uuid(&conn, uuid)))?;
585                match credential {
586                    Some((_secret, comment)) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
587                    None => Err(Error::NoEntry),
588                }
589            }
590            None => {
591                let matches = map_turso(block_on(fetch_credentials_by_id(&conn, &self.id)))?;
592                match matches.len() {
593                    0 => Err(Error::NoEntry),
594                    1 => Ok(attributes_for_uuid(
595                        matches[0].0.as_str(),
596                        matches[0].2.clone(),
597                    )),
598                    _ => Err(Error::Ambiguous(ambiguous_entries(
599                        Arc::clone(&self.inner),
600                        &self.id,
601                        matches.into_iter().map(|pair| pair.0).collect(),
602                    ))),
603                }
604            }
605        }
606    }
607
608    fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
609        parse_attributes(&["comment"], Some(attrs))?;
610        let comment = attrs.get("comment").map(|value| value.to_string());
611        if comment.is_none() {
612            self.get_attributes()?;
613            return Ok(());
614        }
615        let make_comment_value = || match comment.as_ref() {
616            Some(value) => Value::Text(value.to_string()),
617            None => Value::Null,
618        };
619        let conn = self.inner.connect()?;
620        block_on(async {
621            conn.execute("BEGIN IMMEDIATE", ())
622                .await
623                .map_err(map_turso_err)?;
624            let result = match &self.uuid {
625                Some(uuid) => {
626                    let updated = conn
627                        .execute(
628                            "UPDATE credentials SET comment = ?1 WHERE uuid = ?2",
629                            (make_comment_value(), uuid.as_str()),
630                        )
631                        .await
632                        .map_err(map_turso_err)?;
633                    if updated == 0 {
634                        Err(Error::NoEntry)
635                    } else {
636                        Ok(())
637                    }
638                }
639                None if self.inner.allow_ambiguity => {
640                    let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
641                    match uuids.len() {
642                        0 => Err(Error::NoEntry),
643                        1 => {
644                            conn.execute(
645                                "UPDATE credentials SET comment = ?1 WHERE uuid = ?2",
646                                (make_comment_value(), uuids[0].as_str()),
647                            )
648                            .await
649                            .map_err(map_turso_err)?;
650                            Ok(())
651                        }
652                        _ => Err(Error::Ambiguous(ambiguous_entries(
653                            Arc::clone(&self.inner),
654                            &self.id,
655                            uuids,
656                        ))),
657                    }
658                }
659                None => {
660                    let updated = conn
661                        .execute(
662                            "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
663                            (
664                                make_comment_value(),
665                                self.id.service.as_str(),
666                                self.id.user.as_str(),
667                            ),
668                        )
669                        .await
670                        .map_err(map_turso_err)?;
671                    if updated == 0 {
672                        Err(Error::NoEntry)
673                    } else {
674                        Ok(())
675                    }
676                }
677            };
678            match result {
679                Ok(()) => {
680                    conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
681                    Ok(())
682                }
683                Err(err) => {
684                    let _ = conn.execute("ROLLBACK", ()).await;
685                    Err(err)
686                }
687            }
688        })
689    }
690
691    fn delete_credential(&self) -> Result<()> {
692        validate_service_user(&self.id.service, &self.id.user)?;
693        let conn = self.inner.connect()?;
694        match &self.uuid {
695            Some(uuid) => {
696                let deleted = map_turso(block_on(
697                    conn.execute("DELETE FROM credentials WHERE uuid = ?1", (uuid.as_str(),)),
698                ))?;
699                if deleted == 0 {
700                    Err(Error::NoEntry)
701                } else {
702                    Ok(())
703                }
704            }
705            None => {
706                let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
707                match uuids.len() {
708                    0 => Err(Error::NoEntry),
709                    1 => {
710                        map_turso(block_on(conn.execute(
711                            "DELETE FROM credentials WHERE uuid = ?1",
712                            (uuids[0].as_str(),),
713                        )))?;
714                        Ok(())
715                    }
716                    _ => Err(Error::Ambiguous(ambiguous_entries(
717                        Arc::clone(&self.inner),
718                        &self.id,
719                        uuids,
720                    ))),
721                }
722            }
723        }
724    }
725
726    fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
727        validate_service_user(&self.id.service, &self.id.user)?;
728        let conn = self.inner.connect()?;
729        let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
730        match uuids.len() {
731            0 => Err(Error::NoEntry),
732            1 => Ok(Some(Arc::new(DbKeyCredential {
733                inner: Arc::clone(&self.inner),
734                id: self.id.clone(),
735                uuid: Some(uuids[0].clone()),
736                comment: None,
737            }))),
738            _ => Err(Error::Ambiguous(ambiguous_entries(
739                Arc::clone(&self.inner),
740                &self.id,
741                uuids,
742            ))),
743        }
744    }
745
746    fn get_specifiers(&self) -> Option<(String, String)> {
747        Some((self.id.service.clone(), self.id.user.clone()))
748    }
749
750    fn as_any(&self) -> &dyn std::any::Any {
751        self
752    }
753
754    fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
755        fmt::Debug::fmt(self, f)
756    }
757}
758
759fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
760    map_turso(block_on(conn.execute(
761        "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
762        (),
763    )))?;
764    map_turso(block_on(conn.execute(
765        "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
766        (),
767    )))?;
768    ensure_schema_version(conn)?;
769    if !allow_ambiguity {
770        // unique index used to help ensure non-ambiguity of (service,user)
771        map_turso(block_on(conn.execute(
772            "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
773            (),
774        )))?;
775    } else if index_always {
776        // Performance tradeoffs: this index roughly doubles the file size.
777        // - For keystores with ~100 entries, it saves 0.1ms per lookup (.17 vs .28 ms).
778        // - For ~1000 entries, the index saves ~1ms per lookup (0.1 vs 1.4 ms)
779        // - Measured on a m3 macbook air.
780        map_turso(block_on(conn.execute(
781             "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
782             (),
783            )))?;
784    }
785    Ok(())
786}
787
788fn ensure_schema_version(conn: &Connection) -> Result<()> {
789    map_turso(block_on(async {
790        let mut rows = conn
791            .query(
792                "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
793                (),
794            )
795            .await?;
796        if let Some(row) = rows.next().await? {
797            let value = value_to_string(row.get_value(0)?, "schema_version")?;
798            let version = value.parse::<u32>().map_err(|_| {
799                turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
800            })?;
801            if version != SCHEMA_VERSION {
802                return Err(turso::Error::ConversionFailure(format!(
803                    "unsupported schema version: {version}"
804                )));
805            }
806        } else {
807            conn.execute(
808                "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
809                (SCHEMA_VERSION.to_string(),),
810            )
811            .await?;
812        }
813        Ok(())
814    }))
815}
816
817async fn query_all_credentials(
818    conn: &Connection,
819) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
820    let mut rows = conn
821        .query("SELECT service, user, uuid, comment FROM credentials", ())
822        .await?;
823    let mut results = Vec::new();
824    while let Some(row) = rows.next().await? {
825        let service = value_to_string(row.get_value(0)?, "service")?;
826        let user = value_to_string(row.get_value(1)?, "user")?;
827        let uuid = value_to_string(row.get_value(2)?, "uuid")?;
828        let comment = value_to_option_string(row.get_value(3)?, "comment")?;
829        results.push((CredId { service, user }, uuid, comment));
830    }
831    Ok(results)
832}
833
834async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
835    let mut rows = conn
836        .query(
837            "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
838            (id.service.as_str(), id.user.as_str()),
839        )
840        .await?;
841    let mut uuids = Vec::new();
842    while let Some(row) = rows.next().await? {
843        let uuid = value_to_string(row.get_value(0)?, "uuid")?;
844        uuids.push(uuid);
845    }
846    Ok(uuids)
847}
848
849async fn fetch_credential_by_uuid(
850    conn: &Connection,
851    uuid: &str,
852) -> turso::Result<Option<(Vec<u8>, Option<String>)>> {
853    let mut rows = conn
854        .query(
855            "SELECT secret, comment FROM credentials WHERE uuid = ?1",
856            (uuid,),
857        )
858        .await?;
859    let Some(row) = rows.next().await? else {
860        return Ok(None);
861    };
862    let secret = value_to_bytes(row.get_value(0)?, "secret")?;
863    let comment = value_to_option_string(row.get_value(1)?, "comment")?;
864    Ok(Some((secret, comment)))
865}
866
867async fn fetch_credentials_by_id(
868    conn: &Connection,
869    id: &CredId,
870) -> turso::Result<Vec<(String, Vec<u8>, Option<String>)>> {
871    let mut rows = conn
872        .query(
873            "SELECT uuid, secret, comment FROM credentials WHERE service = ?1 AND user = ?2",
874            (id.service.as_str(), id.user.as_str()),
875        )
876        .await?;
877    let mut results = Vec::new();
878    while let Some(row) = rows.next().await? {
879        let uuid = value_to_string(row.get_value(0)?, "uuid")?;
880        let secret = value_to_bytes(row.get_value(1)?, "secret")?;
881        let comment = value_to_option_string(row.get_value(2)?, "comment")?;
882        results.push((uuid, secret, comment));
883    }
884    Ok(results)
885}
886
887fn ambiguous_entries(inner: Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
888    uuids
889        .into_iter()
890        .map(|uuid| {
891            Entry::new_with_credential(Arc::new(DbKeyCredential {
892                inner: Arc::clone(&inner),
893                id: id.clone(),
894                uuid: Some(uuid),
895                comment: None,
896            }))
897        })
898        .collect()
899}
900
901fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
902    let mut attrs = HashMap::new();
903    attrs.insert("uuid".to_string(), uuid.to_string());
904    if let Some(comment) = comment {
905        attrs.insert("comment".to_string(), comment);
906    }
907    attrs
908}
909
910// Database configuration
911// - Enable Write-Ahead Logging for better concurrency (less blocking)
912// - Sets busy timeout - how long to wait when db is locked by another connection before returning error
913fn configure_connection(conn: &Connection) -> Result<()> {
914    map_turso(block_on(async {
915        let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
916        let _ = rows.next().await?;
917        let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
918        conn.execute(busy_stmt.as_str(), ()).await?;
919        Ok(())
920    }))
921}
922
923/// Opens database. Retries with exponential backoff if the file is locked.
924fn open_db_with_retry(
925    path_str: &str,
926    encryption_opts: Option<EncryptionOpts>,
927    vfs: Option<String>,
928) -> Result<Database> {
929    let mut retries = OPEN_LOCK_RETRIES;
930    let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
931    loop {
932        let mut builder = Builder::new_local(path_str);
933        if let Some(opts) = encryption_opts.clone() {
934            let turso_enc_opts = turso::EncryptionOpts {
935                cipher: opts.cipher,
936                hexkey: opts.hexkey,
937            };
938            builder = builder
939                .experimental_encryption(true)
940                .with_encryption(turso_enc_opts);
941        }
942        if let Some(vfs) = vfs.clone() {
943            builder = builder.with_io(vfs);
944        }
945        match block_on(builder.build()) {
946            Ok(db) => return Ok(db),
947            Err(err) => {
948                if retries == 0 || !is_turso_locking_error(&err) {
949                    return Err(map_turso_err(err));
950                }
951                retries -= 1;
952                let nanos = SystemTime::now()
953                    .duration_since(UNIX_EPOCH)
954                    .unwrap_or_default()
955                    .subsec_nanos();
956                let jitter = (nanos % 20) as u64;
957                std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
958                backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
959            }
960        }
961    }
962}
963
964fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
965    let mut retries = OPEN_LOCK_RETRIES;
966    let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
967    loop {
968        match op() {
969            Ok(value) => return Ok(value),
970            Err(err) => {
971                if retries == 0 || !is_turso_locking_error(&err) {
972                    return Err(map_turso_err(err));
973                }
974                retries -= 1;
975                let nanos = SystemTime::now()
976                    .duration_since(UNIX_EPOCH)
977                    .unwrap_or_default()
978                    .subsec_nanos();
979                let jitter = (nanos % 20) as u64;
980                std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
981                backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
982            }
983        }
984    }
985}
986
987fn is_turso_locking_error(err: &turso::Error) -> bool {
988    let text = err.to_string().to_lowercase();
989    text.contains("locking error")
990        || text.contains("file is locked")
991        || text.contains("database is locked")
992        || text.contains("database is busy")
993        || text.contains("sqlite_busy")
994        || text.contains("sqlite_locked")
995}
996
997fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
998    match value {
999        Value::Text(text) => Ok(text),
1000        Value::Blob(blob) => String::from_utf8(blob)
1001            .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1002        other => Err(turso::Error::ConversionFailure(format!(
1003            "unexpected value for {field}: {other:?}"
1004        ))),
1005    }
1006}
1007
1008fn value_to_bytes(value: Value, field: &str) -> turso::Result<Vec<u8>> {
1009    match value {
1010        Value::Blob(blob) => Ok(blob),
1011        Value::Text(text) => Ok(text.into_bytes()),
1012        other => Err(turso::Error::ConversionFailure(format!(
1013            "unexpected value for {field}: {other:?}"
1014        ))),
1015    }
1016}
1017
1018fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1019    match value {
1020        Value::Null => Ok(None),
1021        Value::Text(text) => Ok(Some(text)),
1022        Value::Blob(blob) => String::from_utf8(blob)
1023            .map(Some)
1024            .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1025        other => Err(turso::Error::ConversionFailure(format!(
1026            "unexpected value for {field}: {other:?}"
1027        ))),
1028    }
1029}
1030
1031fn ensure_parent_dir(path: &Path) -> Result<()> {
1032    let parent = path
1033        .parent()
1034        .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1035    if parent.as_os_str().is_empty() {
1036        return Ok(());
1037    }
1038    std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1039}
1040
1041/// confirm service and user are non-empty and within length bounds
1042fn validate_service_user(service: &str, user: &str) -> Result<()> {
1043    if service.is_empty() {
1044        return Err(Error::Invalid(
1045            "service".to_string(),
1046            "service is empty".to_string(),
1047        ));
1048    }
1049    if user.is_empty() {
1050        return Err(Error::Invalid(
1051            "user".to_string(),
1052            "user is empty".to_string(),
1053        ));
1054    }
1055    if service.len() > MAX_NAME_LEN {
1056        return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN as u32));
1057    }
1058    if user.len() > MAX_NAME_LEN {
1059        return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN as u32));
1060    }
1061    Ok(())
1062}
1063
1064/// confirm secret is within length bounds
1065fn validate_secret(secret: &[u8]) -> Result<()> {
1066    if secret.len() > MAX_SECRET_LEN {
1067        return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN as u32));
1068    }
1069    Ok(())
1070}
1071
1072fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1073    result.map_err(map_turso_err)
1074}
1075
1076fn map_turso_err(err: turso::Error) -> Error {
1077    Error::PlatformFailure(Box::new(err))
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    fn new_store(path: &Path) -> Arc<DbKeyStore> {
1085        let config = DbKeyStoreConfig {
1086            path: path.to_path_buf(),
1087            ..Default::default()
1088        };
1089        DbKeyStore::new(&config).expect("failed to create store")
1090    }
1091
1092    fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1093        store
1094            .build(service, user, None)
1095            .expect("failed to build entry")
1096    }
1097
1098    // test that non-existent parent dir is created on db open
1099    #[test]
1100    fn create_store_creates_parent_dir() {
1101        let dir = tempfile::tempdir().expect("tempdir");
1102        let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1103        let parent = db_path.parent().expect("parent");
1104        assert!(!parent.exists());
1105
1106        let config = DbKeyStoreConfig {
1107            path: db_path.clone(),
1108            ..Default::default()
1109        };
1110        let store = DbKeyStore::new(&config).expect("create store");
1111        assert!(parent.is_dir());
1112
1113        let entry = build_entry(&store, "demo", "alice");
1114        entry.set_password("dromomeryx").expect("set_password");
1115    }
1116
1117    // test round-trip set and search
1118    #[test]
1119    fn set_password_then_search_finds_password() {
1120        let dir = tempfile::tempdir().expect("tempdir");
1121        let path = dir.path().join("keystore.db");
1122        let store = new_store(&path);
1123        let entry = build_entry(&store, "demo", "alice");
1124        entry.set_password("dromomeryx").expect("set_password");
1125
1126        let mut spec = HashMap::new();
1127        spec.insert("service", "demo");
1128        spec.insert("user", "alice");
1129        let results = store.search(&spec).expect("search");
1130        assert_eq!(results.len(), 1);
1131        assert_eq!(results[0].get_password().unwrap(), "dromomeryx");
1132    }
1133
1134    // test with comment search
1135    #[test]
1136    fn comment_attributes_round_trip() {
1137        let dir = tempfile::tempdir().expect("tempdir");
1138        let path = dir.path().join("keystore.db");
1139        let store = new_store(&path);
1140        let entry = build_entry(&store, "demo", "alice");
1141        entry.set_password("dromomeryx").expect("set_password");
1142
1143        let update = HashMap::from([("comment", "note")]);
1144        entry.update_attributes(&update).expect("update_attributes");
1145        let attrs = entry.get_attributes().expect("get_attributes");
1146        assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1147        assert!(attrs.contains_key("uuid"));
1148
1149        let mut spec = HashMap::new();
1150        spec.insert("service", "demo");
1151        spec.insert("user", "alice");
1152        spec.insert("comment", "note");
1153        let results = store.search(&spec).expect("search");
1154        assert_eq!(results.len(), 1);
1155
1156        let uuid = attrs.get("uuid").cloned().expect("get uuid");
1157        let mut spec = HashMap::new();
1158        spec.insert("service", "demo");
1159        spec.insert("user", "alice");
1160        spec.insert("uuid", uuid.as_str());
1161        let results = store.search(&spec).expect("search");
1162        assert_eq!(results.len(), 1);
1163    }
1164
1165    #[test]
1166    fn comment_with_password_round_trip() {
1167        let dir = tempfile::tempdir().expect("tempdir");
1168        let path = dir.path().join("keystore.db");
1169        let store = new_store(&path);
1170        let entry = build_entry(&store, "demo", "alice");
1171        entry.set_password("dromomeryx").expect("set_password");
1172
1173        // set a comment attribute
1174        let update = HashMap::from([("comment", "note")]);
1175        entry.update_attributes(&update).expect("update_attributes");
1176
1177        // then search by comment
1178        let mut spec = HashMap::new();
1179        spec.insert("service", "demo");
1180        spec.insert("user", "alice");
1181        spec.insert("comment", "note");
1182        let results = store.search(&spec).expect("search");
1183        assert_eq!(results.len(), 1);
1184
1185        let found = &results[0];
1186        assert_eq!(
1187            found.get_password().expect("password with comment"),
1188            "dromomeryx"
1189        );
1190        let attrs = found.get_attributes().expect("get_attributes");
1191        assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1192        assert!(attrs.contains_key("uuid"));
1193    }
1194
1195    #[test]
1196    fn build_with_comment_modifier_sets_comment() -> Result<()> {
1197        let dir = tempfile::tempdir().expect("tempdir");
1198        let path = dir.path().join("keystore.db");
1199        let store = new_store(&path);
1200        let entry = store.build(
1201            "demo",
1202            "alice",
1203            Some(&HashMap::from([("comment", "initial")])),
1204        )?;
1205        entry.set_password("dromomeryx")?;
1206
1207        let attrs = entry.get_attributes()?;
1208        assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1209        Ok(())
1210    }
1211
1212    #[test]
1213    fn in_memory_store_round_trip() -> Result<()> {
1214        let config = DbKeyStoreConfig {
1215            vfs: Some("memory".to_string()),
1216            ..Default::default()
1217        };
1218        let store = DbKeyStore::new(&config)?;
1219        let entry = build_entry(&store, "demo", "alice");
1220        entry.set_password("dromomeryx")?;
1221
1222        let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1223        assert_eq!(results.len(), 1);
1224        assert_eq!(results[0].get_password()?, "dromomeryx");
1225        Ok(())
1226    }
1227
1228    // test that unique users in same service have unique keys
1229    #[test]
1230    fn stores_separate_service_user_pairs() -> Result<()> {
1231        let dir = tempfile::tempdir().expect("tempdir");
1232        let path = dir.path().join("keystore.db");
1233        let store = new_store(&path);
1234
1235        build_entry(&store, "myapp", "user1").set_password("pw1")?;
1236        build_entry(&store, "myapp", "user2").set_password("pw2")?;
1237        build_entry(&store, "myapp", "user3").set_password("pw3")?;
1238
1239        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1240        assert_eq!(results.len(), 1);
1241        assert_eq!(results[0].get_password()?, "pw1");
1242
1243        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1244        assert_eq!(results.len(), 1);
1245        assert_eq!(results[0].get_password()?, "pw2");
1246
1247        let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1248        assert_eq!(results.len(), 1);
1249        assert_eq!(results[0].get_password()?, "pw3");
1250        Ok(())
1251    }
1252
1253    // search with regex
1254    #[test]
1255    fn search_regex() -> Result<()> {
1256        let dir = tempfile::tempdir().expect("tempdir");
1257        let path = dir.path().join("keystore.db");
1258        let store = new_store(&path);
1259
1260        build_entry(&store, "myapp", "user1").set_password("pw1")?;
1261        build_entry(&store, "myapp", "user2").set_password("pw2")?;
1262        build_entry(&store, "myapp", "user3").set_password("pw3")?;
1263        build_entry(&store, "other-app", "user1").set_password("pw4")?;
1264
1265        // regex search: all apps, user1
1266        let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1267        assert_eq!(results.len(), 2, "search *app, user1");
1268
1269        // regex search _or_
1270        let results = store.search(&HashMap::from([
1271            ("service", "myapp"),
1272            ("user", "user1|user2"),
1273        ]))?;
1274        assert_eq!(results.len(), 2, "search regex OR");
1275
1276        Ok(())
1277    }
1278
1279    // search with partial hashmap
1280    #[test]
1281    fn search_partial() -> Result<()> {
1282        let dir = tempfile::tempdir().expect("tempdir");
1283        let path = dir.path().join("keystore.db");
1284        let store = new_store(&path);
1285
1286        // empty db has no entries
1287        let results = store.search(&HashMap::new())?;
1288        assert_eq!(results.len(), 0, "empty db, no results");
1289
1290        build_entry(&store, "myapp", "user1").set_password("pw1")?;
1291        build_entry(&store, "other-app", "user1").set_password("pw2")?;
1292
1293        // empty search terms match all
1294        let results = store.search(&HashMap::new())?;
1295        assert_eq!(results.len(), 2, "search, empty hashmap");
1296
1297        // app-only match
1298        let results = store.search(&HashMap::from([("service", "myapp")]))?;
1299        assert_eq!(results.len(), 1, "search myapp");
1300
1301        // user-only match
1302        let results = store.search(&HashMap::from([("user", "user1")]))?;
1303        assert_eq!(results.len(), 2, "search user1");
1304        Ok(())
1305    }
1306
1307    // replacement
1308    #[test]
1309    fn repeated_set_replaces_secret() {
1310        let dir = tempfile::tempdir().expect("tempdir");
1311        let path = dir.path().join("keystore.db");
1312        let store = new_store(&path);
1313        let entry = build_entry(&store, "demo", "alice");
1314        entry.set_password("first").expect("password set 1");
1315        entry.set_secret(b"second").expect("password set 2");
1316
1317        let mut spec = HashMap::new();
1318        spec.insert("service", "demo");
1319        spec.insert("user", "alice");
1320        let results = store.search(&spec).expect("search");
1321        assert_eq!(results.len(), 1);
1322        assert_eq!(
1323            results[0].get_password().expect("get first password"),
1324            "second",
1325            "second password overwrites first"
1326        );
1327    }
1328
1329    #[test]
1330    fn same_service_user_entries_share_credential() -> Result<()> {
1331        let dir = tempfile::tempdir().expect("tempdir");
1332        let path = dir.path().join("keystore.db");
1333        let store = new_store(&path);
1334        let entry1 = build_entry(&store, "demo", "alice");
1335        let entry2 = build_entry(&store, "demo", "alice");
1336
1337        entry1.set_password("first")?;
1338        assert_eq!(entry2.get_password()?, "first");
1339
1340        entry2.set_password("second")?;
1341        assert_eq!(entry1.get_password()?, "second");
1342        Ok(())
1343    }
1344
1345    // deletion returns NoEntry if there is no matching entry
1346    #[test]
1347    fn remove_returns_no_entry() {
1348        let dir = tempfile::tempdir().expect("tempdir");
1349        let path = dir.path().join("keystore.db");
1350        let store = new_store(&path);
1351        let entry = build_entry(&store, "demo", "alice");
1352        entry.set_password("dromomeryx").expect("set password");
1353        entry.delete_credential().expect("delete credential");
1354        let err = entry.delete_credential().unwrap_err();
1355        assert!(matches!(err, Error::NoEntry));
1356    }
1357
1358    // deletion actually deletes
1359    #[test]
1360    fn remove_clears_secret() {
1361        let dir = tempfile::tempdir().expect("tempdir");
1362        let path = dir.path().join("keystore.db");
1363        let store = new_store(&path);
1364        let entry = build_entry(&store, "service", "user");
1365        entry.set_password("dromomeryx").expect("set password");
1366        entry.delete_credential().expect("delete credential");
1367
1368        let mut spec = HashMap::new();
1369        spec.insert("service", "demo");
1370        spec.insert("user", "alice");
1371        let results = store.search(&spec).expect("search");
1372        assert!(results.is_empty());
1373    }
1374
1375    #[test]
1376    fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1377        let dir = tempfile::tempdir().expect("tempdir");
1378        let path = dir.path().join("keystore.db");
1379        let config = DbKeyStoreConfig {
1380            path: path.clone(),
1381            allow_ambiguity: true,
1382            ..Default::default()
1383        };
1384        let store = DbKeyStore::new(&config)?;
1385
1386        let uuid1 = Uuid::new_v4().to_string();
1387        let uuid2 = Uuid::new_v4().to_string();
1388        let entry1 = store.build(
1389            "demo",
1390            "alice",
1391            Some(&HashMap::from([
1392                ("uuid", uuid1.as_str()),
1393                ("comment", "one"),
1394            ])),
1395        )?;
1396        let entry2 = store.build(
1397            "demo",
1398            "alice",
1399            Some(&HashMap::from([
1400                ("uuid", uuid2.as_str()),
1401                ("comment", "two"),
1402            ])),
1403        )?;
1404        entry1.set_password("first")?;
1405        entry2.set_password("second")?;
1406
1407        let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1408        assert_eq!(results.len(), 2);
1409
1410        let entry3 = build_entry(&store, "demo", "alice");
1411        let err = entry3.get_password().unwrap_err();
1412        assert!(matches!(err, Error::Ambiguous(_)));
1413        Ok(())
1414    }
1415
1416    #[test]
1417    fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1418        let dir = tempfile::tempdir().expect("tempdir");
1419        let path = dir.path().join("keystore.db");
1420        let store = new_store(&path);
1421
1422        let uuid1 = Uuid::new_v4().to_string();
1423        let uuid2 = Uuid::new_v4().to_string();
1424        let entry1 = store.build(
1425            "demo",
1426            "alice",
1427            Some(&HashMap::from([("uuid", uuid1.as_str())])),
1428        )?;
1429        let entry2 = store.build(
1430            "demo",
1431            "alice",
1432            Some(&HashMap::from([("uuid", uuid2.as_str())])),
1433        )?;
1434
1435        entry1.set_password("first")?;
1436        let err = entry2.set_password("second").unwrap_err();
1437        assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1438        Ok(())
1439    }
1440
1441    #[test]
1442    fn impl_debug() -> Result<()> {
1443        let dir = tempfile::tempdir().expect("tempdir");
1444
1445        let path = dir.path().join("keystore1.db");
1446        let store = new_store(&path);
1447        eprintln!("basic: {store:?}");
1448
1449        let path = dir.path().join("keystore2.db");
1450        let config = DbKeyStoreConfig {
1451            path: path.to_path_buf(),
1452            encryption_opts: Some(EncryptionOpts {
1453                cipher: "aes256gcm".into(),
1454                hexkey: "0000000011111111222222223333333344444444555555556666666677777777".into(),
1455            }),
1456            ..Default::default()
1457        };
1458        let store = DbKeyStore::new(&config)?;
1459        eprintln!("with_enc: {store:?}");
1460
1461        let config = DbKeyStoreConfig {
1462            vfs: Some("memory".to_string()),
1463            ..Default::default()
1464        };
1465        let store = DbKeyStore::new(&config)?;
1466        eprintln!("memory: {store:?}");
1467        Ok(())
1468    }
1469}