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