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