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