1#![warn(clippy::pedantic)]
63#![allow(clippy::missing_errors_doc)]
64#![allow(clippy::must_use_candidate)]
65
66use std::{
81 collections::HashMap,
82 fmt,
83 path::{Path, PathBuf},
84 sync::Arc,
85 time::{SystemTime, UNIX_EPOCH},
86};
87
88use futures::executor::block_on;
89use keyring_core::{
90 api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
91 attributes::parse_attributes,
92 {Credential, Entry, Error, Result},
93};
94use regex::Regex;
95use turso::{Builder, Connection, Database, Value};
96use zeroize::Zeroizing;
97
98pub mod rekey;
99pub use rekey::{RekeyError, RekeyOutcome, SensitiveKey};
100#[cfg(target_os = "linux")]
101pub use rekey::{rekey_at, verify_at};
102
103const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
104
105const MAX_NAME_LEN: u32 = 1024;
109const MAX_SECRET_LEN: u32 = 65536;
110const SCHEMA_VERSION: u32 = 1;
111const BUSY_TIMEOUT_MS: u32 = 5000;
113const OPEN_LOCK_RETRIES: u32 = 60;
115const OPEN_LOCK_BACKOFF_MS: u64 = 20;
116const OPEN_LOCK_BACKOFF_MAX_MS: u64 = 250;
117
118#[derive(Clone)]
127pub struct EncryptionOpts {
128 cipher: String,
129 key: SensitiveKey,
130}
131
132impl EncryptionOpts {
133 pub fn new(cipher: &str, hexkey: &str) -> Result<Self> {
140 let key = SensitiveKey::from_hex(hexkey)
141 .map_err(|e| Error::Invalid("hexkey".to_string(), e.to_string()))?;
142 Self::with_key(cipher, key)
143 }
144
145 pub fn with_key(cipher: &str, key: SensitiveKey) -> Result<Self> {
147 if cipher.is_empty() {
148 return Err(Error::Invalid(
149 "cipher".to_string(),
150 "cipher must not be empty".to_string(),
151 ));
152 }
153 if let Some(expected) = cipher_key_len(cipher)
154 && expected != key.len()
155 {
156 return Err(Error::Invalid(
157 "hexkey".to_string(),
158 format!(
159 "cipher '{cipher}' requires a {expected}-byte key ({} hex chars)",
160 expected * 2
161 ),
162 ));
163 }
164 Ok(Self {
165 cipher: cipher.to_string(),
166 key,
167 })
168 }
169
170 pub fn cipher(&self) -> &str {
172 &self.cipher
173 }
174
175 pub fn key_bytes(&self) -> &[u8] {
177 self.key.as_bytes()
178 }
179
180 pub(crate) fn key_hex(&self) -> Zeroizing<String> {
182 self.key.to_hex()
183 }
184}
185
186impl fmt::Debug for EncryptionOpts {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.debug_struct("EncryptionOpts")
189 .field("cipher", &self.cipher)
190 .field("key", &"<redacted>")
191 .finish()
192 }
193}
194
195pub(crate) fn cipher_key_len(cipher: &str) -> Option<usize> {
198 let normalized: String = cipher
199 .to_ascii_lowercase()
200 .chars()
201 .filter(|c| *c != '-' && *c != '_')
202 .collect();
203 match normalized.as_str() {
204 "aegis128l" | "aegis128x2" | "aegis128x4" | "aes128gcm" => Some(16),
205 "aegis256" | "aegis256x2" | "aegis256x4" | "aes256gcm" => Some(32),
206 _ => None,
207 }
208}
209
210pub(crate) fn turso_encryption_opts(opts: &EncryptionOpts) -> turso::EncryptionOpts {
217 let hexkey = opts.key_hex();
218 turso::EncryptionOpts {
219 cipher: opts.cipher().to_string(),
220 hexkey: hexkey.as_str().to_string(),
221 }
222}
223
224fn new_uuid() -> String {
228 uuid::Uuid::now_v7().to_string()
229}
230
231#[derive(Debug, Default, Clone)]
233pub struct DbKeyStoreConfig {
234 pub path: PathBuf,
236
237 pub encryption_opts: Option<EncryptionOpts>,
239
240 pub allow_ambiguity: bool,
242
243 pub vfs: Option<String>,
248
249 pub index_always: bool,
252}
253
254pub fn default_path() -> Result<PathBuf> {
256 Ok(match std::env::var("XDG_STATE_HOME") {
257 Ok(dir) => PathBuf::from(dir),
258 _ => match std::env::var("HOME") {
259 Ok(home) => PathBuf::from(home).join(".local").join("state"),
260 _ => {
261 return Err(Error::Invalid(
262 "path".to_owned(),
263 "No default path: set 'path' in Config (or modifiers), or define XDG_STATE_HOME or HOME"
264 .to_owned(),
265 ));
266 }
267 },
268 }
269 .join("keystore.db"))
270}
271
272#[derive(Clone)]
273pub struct DbKeyStore {
274 inner: Arc<DbKeyStoreInner>,
275}
276
277#[derive(Debug)]
278struct DbKeyStoreInner {
279 backend: Backend,
280 id: String,
281 allow_ambiguity: bool,
282 encrypted: bool,
283 path: String,
284}
285
286#[derive(Debug)]
298enum Backend {
299 Memory(Database),
301 File {
304 encryption_opts: Option<EncryptionOpts>,
305 vfs: Option<String>,
306 },
307}
308
309struct DbSession {
315 db: Option<Database>,
318 conn: Connection,
319}
320
321impl std::ops::Deref for DbSession {
322 type Target = Connection;
323 fn deref(&self) -> &Connection {
324 &self.conn
325 }
326}
327
328impl Drop for DbSession {
329 fn drop(&mut self) {
330 if self.db.is_some() {
331 checkpoint_before_close(&self.conn);
332 }
333 }
334}
335
336fn checkpoint_before_close(conn: &Connection) {
348 let result = block_on(async {
349 let mut rows = conn.query("PRAGMA wal_checkpoint(TRUNCATE)", ()).await?;
350 while (rows.next().await?).is_some() {}
351 Ok::<(), turso::Error>(())
352 });
353 if let Err(err) = result {
354 log::debug!("wal_checkpoint(TRUNCATE) on close failed: {err}");
355 }
356}
357
358#[derive(Debug, Clone, Eq, PartialEq, Hash)]
359struct CredId {
360 service: String,
361 user: String,
362}
363
364#[derive(Debug, Clone)]
365struct DbKeyCredential {
366 inner: Arc<DbKeyStoreInner>,
367 id: CredId,
368 uuid: Option<String>,
369 comment: Option<String>,
370}
371
372#[derive(Debug)]
373enum LookupResult<T> {
374 None,
375 One(T),
376 Ambiguous(Vec<String>),
377}
378
379#[derive(Debug)]
380struct CommentRow {
381 uuid: String,
382 comment: Option<String>,
383}
384
385impl DbKeyStore {
386 pub fn new(config: DbKeyStoreConfig) -> Result<Arc<DbKeyStore>> {
387 let start_time = SystemTime::now()
388 .duration_since(UNIX_EPOCH)
389 .unwrap_or_default()
390 .as_secs_f64();
391 let encryption_opts = config.encryption_opts;
393 if let Some(vfs) = &config.vfs
394 && vfs == "memory"
395 {
396 let db = map_turso(block_on(async {
398 Builder::new_local(":memory:")
399 .with_io("memory".into())
400 .build()
401 .await
402 }))?;
403 let id = format!("DbKeyStore v{CRATE_VERSION} in-memory @ {start_time}");
404 let conn = map_turso(db.connect())?;
405 init_schema(&conn, config.allow_ambiguity, config.index_always)?;
406 return Ok(Arc::new(DbKeyStore {
407 inner: Arc::new(DbKeyStoreInner {
408 backend: Backend::Memory(db),
409 id,
410 allow_ambiguity: config.allow_ambiguity,
411 encrypted: false,
412 path: ":memory:".to_string(),
413 }),
414 }));
415 }
416 let path = if config.path.as_os_str().is_empty() {
417 default_path()?
418 } else {
419 config.path.clone()
420 };
421 let path_str = path
423 .to_str()
424 .ok_or_else(|| Error::Invalid("path".into(), "path must be valid UTF-8".to_string()))?;
425 ensure_parent_dir(&path)?;
426 let encrypted = encryption_opts.is_some();
427 {
432 let db = open_db_with_retry(path_str, encryption_opts.as_ref(), config.vfs.as_deref())?;
433 let conn = retry_turso_locking(|| db.connect())?;
434 configure_connection(&conn)?;
435 init_schema(&conn, config.allow_ambiguity, config.index_always)?;
436 checkpoint_before_close(&conn);
437 }
438 let id =
439 format!("DbKeyStore v{CRATE_VERSION} path:{path_str} enc:{encrypted} @ {start_time}");
440 Ok(Arc::new(DbKeyStore {
441 inner: Arc::new(DbKeyStoreInner {
442 backend: Backend::File {
443 encryption_opts,
444 vfs: config.vfs.clone(),
445 },
446 id,
447 allow_ambiguity: config.allow_ambiguity,
448 encrypted,
449 path: path_str.to_string(),
450 }),
451 }))
452 }
453
454 pub fn new_with_modifiers(modifiers: &HashMap<&str, &str>) -> Result<Arc<DbKeyStore>> {
455 let mut mods = parse_attributes(
457 &[
458 "path",
459 "encryption-cipher",
460 "cipher",
461 "encryption-hexkey",
462 "hexkey",
463 "*allow-ambiguity",
464 "*allow_ambiguity",
465 "vfs",
466 "*index-always",
467 "*index_always",
468 ],
469 Some(modifiers),
470 )?;
471 let path = mods.remove("path").map(PathBuf::from).unwrap_or_default();
472 let cipher = mods
473 .remove("encryption-cipher")
474 .or_else(|| mods.remove("cipher"));
475 let hexkey = mods
477 .remove("encryption-hexkey")
478 .or_else(|| mods.remove("hexkey"))
479 .map(Zeroizing::new);
480 let allow_ambiguity = mods
481 .remove("allow-ambiguity")
482 .or_else(|| mods.remove("allow_ambiguity"))
483 .is_some_and(|value| value == "true");
484 let index_always = mods
485 .remove("index-always")
486 .or_else(|| mods.remove("index_always"))
487 .is_some_and(|value| value == "true");
488 let vfs = mods.remove("vfs");
489 let encryption_opts = match (cipher, hexkey) {
490 (None, None) => None,
491 (Some(cipher), Some(hexkey)) => Some(EncryptionOpts::new(&cipher, hexkey.as_str())?),
492 _ => {
493 return Err(Error::Invalid(
494 "encryption".to_string(),
495 "encryption-cipher and encryption-hexkey must both be set".to_string(),
496 ));
497 }
498 };
499 let config = DbKeyStoreConfig {
500 path,
501 encryption_opts,
502 allow_ambiguity,
503 vfs,
504 index_always,
505 };
506 DbKeyStore::new(config)
507 }
508
509 pub fn is_encrypted(&self) -> bool {
511 self.inner.encrypted
512 }
513
514 pub fn path(&self) -> String {
516 self.inner.path.clone()
517 }
518}
519
520impl std::fmt::Debug for DbKeyStore {
521 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522 f.debug_struct("DbKeyStore")
523 .field("vendor", &self.vendor())
524 .field("id", &self.id())
525 .field("allow_ambiguity", &self.inner.allow_ambiguity)
526 .finish()
527 }
528}
529
530impl DbKeyStoreInner {
531 fn connect(&self) -> Result<DbSession> {
537 match &self.backend {
538 Backend::Memory(db) => {
539 let conn = map_turso(db.connect())?;
540 configure_connection(&conn)?;
541 Ok(DbSession { db: None, conn })
542 }
543 Backend::File {
544 encryption_opts,
545 vfs,
546 } => {
547 let db =
548 open_db_with_retry(&self.path, encryption_opts.as_ref(), vfs.as_deref())?;
549 let conn = retry_turso_locking(|| db.connect())?;
550 configure_connection(&conn)?;
551 Ok(DbSession {
552 db: Some(db),
553 conn,
554 })
555 }
556 }
557 }
558}
559
560impl DbKeyCredential {
561 async fn insert_credential(
562 &self,
563 conn: &Connection,
564 uuid: &str,
565 secret: Value,
566 comment: Value,
567 ) -> Result<()> {
568 conn.execute(
569 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
570 (
571 self.id.service.as_str(),
572 self.id.user.as_str(),
573 uuid,
574 secret,
575 comment,
576 ),
577 )
578 .await
579 .map_err(map_turso_err)?;
580 Ok(())
581 }
582}
583
584impl CredentialStoreApi for DbKeyStore {
585 fn vendor(&self) -> String {
586 String::from("DbKeyStore, https://crates.io/crates/db-keystore")
587 }
588
589 fn id(&self) -> String {
590 self.inner.id.clone()
591 }
592
593 fn build(
597 &self,
598 service: &str,
599 user: &str,
600 modifiers: Option<&HashMap<&str, &str>>,
601 ) -> Result<Entry> {
602 validate_service_user(service, user)?;
603 let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
604 let credential = DbKeyCredential {
605 inner: Arc::clone(&self.inner),
606 id: CredId {
607 service: service.to_string(),
608 user: user.to_string(),
609 },
610 uuid: mods
611 .get("uuid")
612 .map(|value| normalize_uuid_input(value))
613 .transpose()?,
614 comment: mods.get("comment").cloned(),
615 };
616 Ok(Entry::new_with_credential(Arc::new(credential)))
617 }
618
619 fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
626 let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
627 let service_re = Regex::new(spec.get("service").map_or("", String::as_str))
628 .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
629 let user_re = Regex::new(spec.get("user").map_or("", String::as_str))
630 .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
631 let comment_re = Regex::new(spec.get("comment").map_or("", String::as_str))
632 .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
633 let uuid_spec = match spec.get("uuid") {
634 Some(value) => Some(normalize_uuid_input(value)?),
635 None => None,
636 };
637 let uuid_re = Regex::new(uuid_spec.as_deref().unwrap_or(""))
638 .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
639 let conn = self.inner.connect()?;
640 let rows = map_turso(block_on(query_all_credentials(&conn)))?;
641 let mut entries = Vec::new();
642 let comment_filter = spec.get("comment").cloned();
643 let filter_comment = spec.contains_key("comment");
644 let filter_comment_empty = comment_filter.as_deref().is_some_and(str::is_empty);
645 for (id, uuid, comment) in rows {
646 if !service_re.is_match(id.service.as_str()) {
647 continue;
648 }
649 if !user_re.is_match(id.user.as_str()) {
650 continue;
651 }
652 if !uuid_re.is_match(uuid.as_str()) {
653 continue;
654 }
655 if filter_comment {
656 if filter_comment_empty {
657 if comment.as_deref().is_some_and(|value| !value.is_empty()) {
659 continue;
660 }
661 } else {
662 match comment.as_ref() {
664 Some(text) if comment_re.is_match(text.as_str()) => {}
665 _ => continue,
666 }
667 }
668 }
669 let credential = DbKeyCredential {
670 inner: Arc::clone(&self.inner),
671 id,
672 uuid: Some(uuid),
673 comment: None,
674 };
675 entries.push(Entry::new_with_credential(Arc::new(credential)));
676 }
677 Ok(entries)
678 }
679
680 fn as_any(&self) -> &dyn std::any::Any {
681 self
682 }
683
684 fn persistence(&self) -> CredentialPersistence {
685 CredentialPersistence::UntilDelete
686 }
687
688 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
689 fmt::Debug::fmt(self, f)
690 }
691}
692
693impl DbKeyCredential {
694 fn get_secret_zeroizing(&self) -> Result<Zeroizing<Vec<u8>>> {
695 validate_service_user(&self.id.service, &self.id.user)?;
696 let conn = self.inner.connect()?;
697 if let Some(uuid) = &self.uuid {
698 let match_result = map_turso(block_on(fetch_secret_by_key(&conn, &self.id, uuid)))?;
699 match match_result {
700 LookupResult::None => Err(Error::NoEntry),
701 LookupResult::One(secret) => Ok(secret),
702 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
703 &Arc::clone(&self.inner),
704 &self.id,
705 uuids,
706 ))),
707 }
708 } else {
709 let match_result = map_turso(block_on(fetch_secret_by_id(&conn, &self.id)))?;
710 match match_result {
711 LookupResult::None => Err(Error::NoEntry),
712 LookupResult::One(secret) => Ok(secret),
713 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
714 &Arc::clone(&self.inner),
715 &self.id,
716 uuids,
717 ))),
718 }
719 }
720 }
721
722 async fn set_secret_unambiguous(
723 &self,
724 conn: &Connection,
725 make_secret_value: &dyn Fn() -> Value,
726 make_comment_value: &dyn Fn() -> Value,
727 ) -> Result<()> {
728 let uuid = new_uuid();
729 let _ = conn.execute(
730 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
731 ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
732 (
733 self.id.service.as_str(),
734 self.id.user.as_str(),
735 uuid.as_str(),
736 make_secret_value(),
737 make_comment_value(),
738 ),
739 )
740 .await.map_err(map_turso_err)?;
741 Ok(())
742 }
743
744 async fn set_secret_with_uuid(
745 &self,
746 conn: &Connection,
747 uuid: &str,
748 make_secret_value: &dyn Fn() -> Value,
749 make_comment_value: &dyn Fn() -> Value,
750 ) -> Result<()> {
751 let updated = conn
752 .execute(
753 "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
754 (
755 make_secret_value(),
756 self.id.service.as_str(),
757 self.id.user.as_str(),
758 uuid,
759 ),
760 )
761 .await
762 .map_err(map_turso_err)?;
763 if updated > 0 {
764 return Ok(());
765 }
766 if !self.inner.allow_ambiguity {
767 let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
768 match uuids.len() {
769 0 => {}
770 1 => {
771 if uuids[0] != uuid {
772 return Err(Error::Invalid(
773 "uuid".to_string(),
774 "can't create ambiguous credential for service/user".to_string(),
775 ));
776 }
777 }
778 _ => {
779 return Err(Error::PlatformFailure(format!(
781 "Database is in an invalid state: ambiguity not allowed, but multiple entries found for {:?}",
782 self.id
783 ).into()));
784 }
785 }
786 }
787 self.insert_credential(conn, uuid, make_secret_value(), make_comment_value())
788 .await?;
789 Ok(())
790 }
791
792 async fn set_secret_without_uuid(
793 &self,
794 conn: &Connection,
795 make_secret_value: &dyn Fn() -> Value,
796 make_comment_value: &dyn Fn() -> Value,
797 ) -> Result<()> {
798 let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
799 match uuids.len() {
800 0 => {
801 let uuid = new_uuid();
802 self.insert_credential(
803 conn,
804 uuid.as_str(),
805 make_secret_value(),
806 make_comment_value(),
807 )
808 .await?;
809 Ok(())
810 }
811 1 => {
812 conn.execute(
813 "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
814 (
815 make_secret_value(),
816 self.id.service.as_str(),
817 self.id.user.as_str(),
818 uuids[0].as_str(),
819 ),
820 )
821 .await
822 .map_err(map_turso_err)?;
823 Ok(())
824 }
825 _ => Err(Error::Ambiguous(ambiguous_entries(
826 &self.inner,
827 &self.id,
828 uuids,
829 ))),
830 }
831 }
832
833 async fn set_secret_in_tx(
834 &self,
835 conn: &Connection,
836 make_secret_value: &dyn Fn() -> Value,
837 make_comment_value: &dyn Fn() -> Value,
838 ) -> Result<()> {
839 if let Some(uuid) = &self.uuid {
840 self.set_secret_with_uuid(conn, uuid.as_str(), make_secret_value, make_comment_value)
841 .await
842 } else {
843 self.set_secret_without_uuid(conn, make_secret_value, make_comment_value)
844 .await
845 }
846 }
847
848 async fn finish_tx(conn: &Connection, result: Result<()>) -> Result<()> {
849 match result {
850 Ok(()) => {
851 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
852 Ok(())
853 }
854 Err(err) => {
855 if let Err(e2) = conn.execute("ROLLBACK", ()).await {
856 log::error!(
857 "While handling set_secret error ({err:?}). attempted ROLLBACK, which encountered secondary error: {e2:?}"
858 );
859 }
860 Err(err)
861 }
862 }
863 }
864}
865
866impl CredentialApi for DbKeyCredential {
867 fn set_secret(&self, secret: &[u8]) -> Result<()> {
868 validate_service_user(&self.id.service, &self.id.user)?;
869 validate_secret(secret)?;
870 let make_secret_value = || Value::Blob(secret.to_vec());
871 let make_comment_value = || comment_value(self.comment.as_ref());
872 let conn = self.inner.connect()?;
873 if self.uuid.is_none() && !self.inner.allow_ambiguity {
874 return block_on(self.set_secret_unambiguous(
875 &conn,
876 &make_secret_value,
877 &make_comment_value,
878 ));
879 }
880 block_on(async {
881 conn.execute("BEGIN IMMEDIATE", ())
882 .await
883 .map_err(map_turso_err)?;
884 let result = self
885 .set_secret_in_tx(&conn, &make_secret_value, &make_comment_value)
886 .await;
887 Self::finish_tx(&conn, result).await
888 })
889 }
890
891 fn get_secret(&self) -> Result<Vec<u8>> {
892 let secret = self.get_secret_zeroizing()?;
893 Ok(take_zeroizing_vec(secret))
894 }
895
896 fn get_attributes(&self) -> Result<HashMap<String, String>> {
897 validate_service_user(&self.id.service, &self.id.user)?;
898 let conn = self.inner.connect()?;
899 if let Some(uuid) = &self.uuid {
900 let match_result = map_turso(block_on(fetch_comment_by_key(&conn, &self.id, uuid)))?;
901 match match_result {
902 LookupResult::None => Err(Error::NoEntry),
903 LookupResult::One(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
904 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
905 &self.inner,
906 &self.id,
907 uuids,
908 ))),
909 }
910 } else {
911 let match_result = map_turso(block_on(fetch_comment_by_id(&conn, &self.id)))?;
912 match match_result {
913 LookupResult::None => Err(Error::NoEntry),
914 LookupResult::One(row) => Ok(attributes_for_uuid(row.uuid.as_str(), row.comment)),
915 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
916 &self.inner,
917 &self.id,
918 uuids,
919 ))),
920 }
921 }
922 }
923
924 fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
925 parse_attributes(&["comment"], Some(attrs))?;
926 let comment = attrs.get("comment").map(ToString::to_string);
927 let has_comment = attrs.contains_key("comment");
928 if !has_comment {
929 self.get_attributes()?;
930 return Ok(());
931 }
932 let comment = comment.filter(|value| !value.is_empty());
933 let make_comment_value = || comment_value(comment.as_ref());
934 let conn = self.inner.connect()?;
935 block_on(async {
936 conn.execute("BEGIN IMMEDIATE", ())
937 .await
938 .map_err(map_turso_err)?;
939 let result = match &self.uuid {
940 Some(uuid) => {
941 let uuids = fetch_uuids_by_key(&conn, &self.id, uuid)
942 .await
943 .map_err(map_turso_err)?;
944 match uuids.len() {
945 0 => Err(Error::NoEntry),
946 1 => {
947 conn.execute(
948 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
949 (
950 make_comment_value(),
951 self.id.service.as_str(),
952 self.id.user.as_str(),
953 uuid.as_str(),
954 ),
955 )
956 .await
957 .map_err(map_turso_err)?;
958 Ok(())
959 }
960 _ => Err(Error::Ambiguous(ambiguous_entries(
961 &self.inner,
962 &self.id,
963 uuids,
964 ))),
965 }
966 }
967 None if self.inner.allow_ambiguity => {
968 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
969 match uuids.len() {
970 0 => Err(Error::NoEntry),
971 1 => {
972 conn.execute(
973 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
974 (
975 make_comment_value(),
976 self.id.service.as_str(),
977 self.id.user.as_str(),
978 uuids[0].as_str(),
979 ),
980 )
981 .await
982 .map_err(map_turso_err)?;
983 Ok(())
984 }
985 _ => Err(Error::Ambiguous(ambiguous_entries(
986 &self.inner,
987 &self.id,
988 uuids,
989 ))),
990 }
991 }
992 None => {
993 let updated = conn
994 .execute(
995 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
996 (
997 make_comment_value(),
998 self.id.service.as_str(),
999 self.id.user.as_str(),
1000 ),
1001 )
1002 .await
1003 .map_err(map_turso_err)?;
1004 if updated == 0 {
1005 Err(Error::NoEntry)
1006 } else {
1007 Ok(())
1008 }
1009 }
1010 };
1011 match result {
1012 Ok(()) => {
1013 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
1014 Ok(())
1015 }
1016 Err(err) => {
1017 let _ = conn.execute("ROLLBACK", ()).await;
1019 Err(err)
1020 }
1021 }
1022 })
1023 }
1024
1025 fn delete_credential(&self) -> Result<()> {
1026 validate_service_user(&self.id.service, &self.id.user)?;
1027 let conn = self.inner.connect()?;
1028 if let Some(uuid) = &self.uuid {
1029 let deleted = map_turso(block_on(conn.execute(
1030 "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1031 (
1032 self.id.service.as_str(),
1033 self.id.user.as_str(),
1034 uuid.as_str(),
1035 ),
1036 )))?;
1037 if deleted == 0 {
1038 Err(Error::NoEntry)
1039 } else {
1040 Ok(())
1041 }
1042 } else {
1043 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
1044 match uuids.len() {
1045 0 => Err(Error::NoEntry),
1046 1 => {
1047 map_turso(block_on(conn.execute(
1048 "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1049 (
1050 self.id.service.as_str(),
1051 self.id.user.as_str(),
1052 uuids[0].as_str(),
1053 ),
1054 )))?;
1055 Ok(())
1056 }
1057 _ => Err(Error::Ambiguous(ambiguous_entries(
1058 &self.inner,
1059 &self.id,
1060 uuids,
1061 ))),
1062 }
1063 }
1064 }
1065
1066 fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
1067 validate_service_user(&self.id.service, &self.id.user)?;
1068 let conn = self.inner.connect()?;
1069 if let Some(uuid) = &self.uuid {
1070 let uuids = map_turso(block_on(fetch_uuids_by_key(&conn, &self.id, uuid)))?;
1071 match uuids.len() {
1072 0 => Err(Error::NoEntry),
1073 1 => Ok(Some(Arc::new(DbKeyCredential {
1074 inner: Arc::clone(&self.inner),
1075 id: self.id.clone(),
1076 uuid: Some(uuid.clone()),
1077 comment: None,
1078 }))),
1079 _ => Err(Error::Ambiguous(ambiguous_entries(
1080 &self.inner,
1081 &self.id,
1082 uuids,
1083 ))),
1084 }
1085 } else {
1086 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
1087 match uuids.len() {
1088 0 => Err(Error::NoEntry),
1089 1 => Ok(Some(Arc::new(DbKeyCredential {
1090 inner: Arc::clone(&self.inner),
1091 id: self.id.clone(),
1092 uuid: Some(uuids[0].clone()),
1093 comment: None,
1094 }))),
1095 _ => Err(Error::Ambiguous(ambiguous_entries(
1096 &self.inner,
1097 &self.id,
1098 uuids,
1099 ))),
1100 }
1101 }
1102 }
1103
1104 fn get_specifiers(&self) -> Option<(String, String)> {
1105 Some((self.id.service.clone(), self.id.user.clone()))
1106 }
1107
1108 fn as_any(&self) -> &dyn std::any::Any {
1109 self
1110 }
1111
1112 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1113 fmt::Debug::fmt(self, f)
1114 }
1115}
1116
1117fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
1118 map_turso(block_on(conn.execute(
1119 "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
1120 (),
1121 )))?;
1122 map_turso(block_on(conn.execute(
1123 "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
1124 (),
1125 )))?;
1126 ensure_schema_version(conn)?;
1127 if !allow_ambiguity {
1128 map_turso(block_on(conn.execute(
1130 "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
1131 (),
1132 )))?;
1133 } else if index_always {
1134 map_turso(block_on(conn.execute(
1139 "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
1140 (),
1141 )))?;
1142 }
1143 Ok(())
1144}
1145
1146fn ensure_schema_version(conn: &Connection) -> Result<()> {
1147 map_turso(block_on(async {
1148 let mut rows = conn
1149 .query(
1150 "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
1151 (),
1152 )
1153 .await?;
1154 if let Some(row) = rows.next().await? {
1155 let value = value_to_string(row.get_value(0)?, "schema_version")?;
1156 let version = value.parse::<u32>().map_err(|_| {
1157 turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
1158 })?;
1159 if version != SCHEMA_VERSION {
1160 return Err(turso::Error::ConversionFailure(format!(
1161 "unsupported schema version: {version}"
1162 )));
1163 }
1164 } else {
1165 conn.execute(
1166 "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
1167 (SCHEMA_VERSION.to_string(),),
1168 )
1169 .await?;
1170 }
1171 Ok(())
1172 }))
1173}
1174
1175async fn query_all_credentials(
1176 conn: &Connection,
1177) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
1178 let mut rows = conn
1179 .query("SELECT service, user, uuid, comment FROM credentials", ())
1180 .await?;
1181 let mut results = Vec::new();
1182 while let Some(row) = rows.next().await? {
1183 let service = value_to_string(row.get_value(0)?, "service")?;
1184 let user = value_to_string(row.get_value(1)?, "user")?;
1185 let uuid = value_to_string(row.get_value(2)?, "uuid")?;
1186 let comment = value_to_option_string(row.get_value(3)?, "comment")?;
1187 results.push((CredId { service, user }, uuid, comment));
1188 }
1189 Ok(results)
1190}
1191
1192async fn schema_has_unique_service_user(conn: &Connection) -> turso::Result<bool> {
1195 let mut rows = conn
1196 .query(
1197 "SELECT sql FROM sqlite_master \
1198 WHERE (type = 'index' AND tbl_name = 'credentials') \
1199 OR (type = 'table' AND name = 'credentials') \
1200 AND sql IS NOT NULL",
1201 (),
1202 )
1203 .await?;
1204 while let Some(row) = rows.next().await? {
1205 match row.get_value(0)? {
1206 Value::Text(sql) if is_unique_service_user_sql(sql.as_str()) => return Ok(true),
1207 _ => {}
1208 }
1209 }
1210 Ok(false)
1211}
1212
1213fn is_unique_service_user_sql(sql: &str) -> bool {
1216 let normalized: String = sql
1217 .chars()
1218 .filter(|c| !c.is_whitespace() && *c != '"' && *c != '`')
1219 .flat_map(char::to_lowercase)
1220 .collect();
1221 normalized.contains("unique") && normalized.contains("(service,user)")
1222}
1223
1224async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
1225 let mut rows = conn
1226 .query(
1227 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
1228 (id.service.as_str(), id.user.as_str()),
1229 )
1230 .await?;
1231 let mut uuids = Vec::new();
1232 while let Some(row) = rows.next().await? {
1233 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1234 uuids.push(uuid);
1235 }
1236 Ok(uuids)
1237}
1238
1239async fn fetch_secret_by_key(
1240 conn: &Connection,
1241 id: &CredId,
1242 uuid: &str,
1243) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1244 let mut rows = conn
1245 .query(
1246 "SELECT secret FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1247 (id.service.as_str(), id.user.as_str(), uuid),
1248 )
1249 .await?;
1250 let mut secrets = Vec::new();
1251 while let Some(row) = rows.next().await? {
1252 let secret = value_to_secret(row.get_value(0)?, "secret")?;
1253 secrets.push(secret);
1254 }
1255 match secrets.len() {
1256 0 => Ok(LookupResult::None),
1257 1 => Ok(LookupResult::One(
1258 secrets.into_iter().next().expect("secret for single match"),
1259 )),
1260 _ => Ok(LookupResult::Ambiguous(vec![
1261 uuid.to_string();
1262 secrets.len()
1263 ])),
1264 }
1265}
1266
1267async fn fetch_comment_by_key(
1268 conn: &Connection,
1269 id: &CredId,
1270 uuid: &str,
1271) -> turso::Result<LookupResult<Option<String>>> {
1272 let mut rows = conn
1273 .query(
1274 "SELECT comment FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1275 (id.service.as_str(), id.user.as_str(), uuid),
1276 )
1277 .await?;
1278 let mut comments = Vec::new();
1279 while let Some(row) = rows.next().await? {
1280 let comment = value_to_option_string(row.get_value(0)?, "comment")?;
1281 comments.push(comment);
1282 }
1283 match comments.len() {
1284 0 => Ok(LookupResult::None),
1285 1 => Ok(LookupResult::One(
1286 comments
1287 .into_iter()
1288 .next()
1289 .expect("comment for single match"),
1290 )),
1291 _ => Ok(LookupResult::Ambiguous(vec![
1292 uuid.to_string();
1293 comments.len()
1294 ])),
1295 }
1296}
1297
1298async fn fetch_secret_by_id(
1299 conn: &Connection,
1300 id: &CredId,
1301) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1302 let uuids = fetch_uuids(conn, id).await?;
1303 match uuids.len() {
1304 0 => Ok(LookupResult::None),
1305 1 => fetch_secret_by_key(conn, id, uuids[0].as_str()).await,
1306 _ => Ok(LookupResult::Ambiguous(uuids)),
1307 }
1308}
1309
1310async fn fetch_comment_by_id(
1311 conn: &Connection,
1312 id: &CredId,
1313) -> turso::Result<LookupResult<CommentRow>> {
1314 let uuids = fetch_uuids(conn, id).await?;
1315 match uuids.len() {
1316 0 => Ok(LookupResult::None),
1317 1 => {
1318 let uuid = uuids.into_iter().next().expect("uuid");
1319 match fetch_comment_by_key(conn, id, uuid.as_str()).await? {
1320 LookupResult::None => Ok(LookupResult::None),
1321 LookupResult::One(comment) => Ok(LookupResult::One(CommentRow { uuid, comment })),
1322 LookupResult::Ambiguous(uuids) => Ok(LookupResult::Ambiguous(uuids)),
1323 }
1324 }
1325 _ => Ok(LookupResult::Ambiguous(uuids)),
1326 }
1327}
1328
1329async fn fetch_uuids_by_key(
1330 conn: &Connection,
1331 id: &CredId,
1332 uuid: &str,
1333) -> turso::Result<Vec<String>> {
1334 let mut rows = conn
1335 .query(
1336 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1337 (id.service.as_str(), id.user.as_str(), uuid),
1338 )
1339 .await?;
1340 let mut uuids = Vec::new();
1341 while let Some(row) = rows.next().await? {
1342 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1343 uuids.push(uuid);
1344 }
1345 Ok(uuids)
1346}
1347
1348fn ambiguous_entries(inner: &Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
1349 uuids
1350 .into_iter()
1351 .map(|uuid| {
1352 Entry::new_with_credential(Arc::new(DbKeyCredential {
1353 inner: Arc::clone(inner),
1354 id: id.clone(),
1355 uuid: Some(uuid),
1356 comment: None,
1357 }))
1358 })
1359 .collect()
1360}
1361
1362fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
1363 let mut attrs = HashMap::new();
1364 attrs.insert("uuid".to_string(), uuid.to_string());
1365 if let Some(comment) = comment {
1366 attrs.insert("comment".to_string(), comment);
1367 }
1368 attrs
1369}
1370
1371fn comment_value(comment: Option<&String>) -> Value {
1372 match comment {
1373 Some(value) if !value.is_empty() => Value::Text(value.clone()),
1374 _ => Value::Null,
1375 }
1376}
1377
1378fn normalize_uuid_input(value: &str) -> Result<String> {
1379 let lower = value.to_ascii_lowercase();
1380 let uuid = uuid::Uuid::try_parse(&lower)
1381 .map_err(|_| Error::Invalid("uuid".to_string(), "invalid uuid format".to_string()))?;
1382 if uuid.to_string() != lower {
1383 return Err(Error::Invalid(
1384 "uuid".to_string(),
1385 "invalid uuid format".to_string(),
1386 ));
1387 }
1388 Ok(lower)
1389}
1390
1391fn take_zeroizing_vec(mut value: Zeroizing<Vec<u8>>) -> Vec<u8> {
1392 std::mem::take(&mut *value)
1393}
1394
1395fn configure_connection(conn: &Connection) -> Result<()> {
1399 map_turso(block_on(async {
1400 let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
1401 let _ = rows.next().await?;
1402 let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
1403 conn.execute(busy_stmt.as_str(), ()).await?;
1404 Ok(())
1405 }))
1406}
1407
1408fn open_db_with_retry(
1410 path_str: &str,
1411 encryption_opts: Option<&EncryptionOpts>,
1412 vfs: Option<&str>,
1413) -> Result<Database> {
1414 let mut retries = OPEN_LOCK_RETRIES;
1415 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1416 loop {
1417 let mut builder = Builder::new_local(path_str);
1418 if let Some(opts) = encryption_opts {
1419 builder = builder
1421 .experimental_encryption(true)
1422 .with_encryption(turso_encryption_opts(opts));
1423 }
1424 if let Some(vfs) = vfs {
1425 builder = builder.with_io(vfs.to_string());
1426 }
1427 match block_on(builder.build()) {
1428 Ok(db) => return Ok(db),
1429 Err(err) => {
1430 check_decryption_error(&err)?;
1431 if retries == 0 || !is_turso_locking_error(&err) {
1432 return Err(map_turso_err(err));
1433 }
1434 retries -= 1;
1435 let nanos = SystemTime::now()
1436 .duration_since(UNIX_EPOCH)
1437 .unwrap_or_default()
1438 .subsec_nanos();
1439 let jitter = u64::from(nanos % 20);
1440 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1441 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1442 }
1443 }
1444 }
1445}
1446
1447fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
1448 let mut retries = OPEN_LOCK_RETRIES;
1449 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1450 loop {
1451 match op() {
1452 Ok(value) => return Ok(value),
1453 Err(err) => {
1454 if retries == 0 || !is_turso_locking_error(&err) {
1455 return Err(map_turso_err(err));
1456 }
1457 retries -= 1;
1458 let nanos = SystemTime::now()
1459 .duration_since(UNIX_EPOCH)
1460 .unwrap_or_default()
1461 .subsec_nanos();
1462 let jitter = u64::from(nanos % 20);
1463 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1464 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1465 }
1466 }
1467 }
1468}
1469
1470fn is_turso_locking_error(err: &turso::Error) -> bool {
1471 let text = err.to_string().to_lowercase();
1472 text.contains("locking error")
1473 || text.contains("file is locked")
1474 || text.contains("database is locked")
1475 || text.contains("database is busy")
1476 || text.contains("sqlite_busy")
1477 || text.contains("sqlite_locked")
1478}
1479
1480fn check_decryption_error(err: &turso::Error) -> Result<()> {
1481 let text = err.to_string();
1482 if text.starts_with("Decryption failed") {
1483 return Err(keyring_core::Error::NoStorageAccess(Box::new(
1484 turso::Error::Error(format!("Invalid encryption key or cipher. {text}")),
1485 )));
1486 }
1487 Ok(())
1488}
1489
1490fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
1491 match value {
1492 Value::Text(text) => Ok(text),
1493 Value::Blob(blob) => String::from_utf8(blob)
1494 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1495 other => Err(turso::Error::ConversionFailure(format!(
1496 "unexpected value for {field}: {other:?}"
1497 ))),
1498 }
1499}
1500
1501fn value_to_secret(value: Value, field: &str) -> turso::Result<Zeroizing<Vec<u8>>> {
1502 match value {
1503 Value::Blob(blob) => Ok(Zeroizing::new(blob)),
1504 Value::Text(text) => Ok(Zeroizing::new(text.into_bytes())),
1505 other => Err(turso::Error::ConversionFailure(format!(
1506 "unexpected value for {field}: {other:?}"
1507 ))),
1508 }
1509}
1510
1511fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1512 match value {
1513 Value::Null => Ok(None),
1514 Value::Text(text) => Ok(Some(text)),
1515 Value::Blob(blob) => String::from_utf8(blob)
1516 .map(Some)
1517 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1518 other => Err(turso::Error::ConversionFailure(format!(
1519 "unexpected value for {field}: {other:?}"
1520 ))),
1521 }
1522}
1523
1524fn ensure_parent_dir(path: &Path) -> Result<()> {
1525 let parent = path
1526 .parent()
1527 .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1528 if parent.as_os_str().is_empty() {
1529 return Ok(());
1530 }
1531 std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1532}
1533
1534fn validate_service_user(service: &str, user: &str) -> Result<()> {
1536 if service.is_empty() {
1537 return Err(Error::Invalid(
1538 "service".to_string(),
1539 "service is empty".to_string(),
1540 ));
1541 }
1542 if user.is_empty() {
1543 return Err(Error::Invalid(
1544 "user".to_string(),
1545 "user is empty".to_string(),
1546 ));
1547 }
1548 if service.len() > MAX_NAME_LEN as usize {
1549 return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN));
1550 }
1551 if user.len() > MAX_NAME_LEN as usize {
1552 return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN));
1553 }
1554 Ok(())
1555}
1556
1557fn validate_secret(secret: &[u8]) -> Result<()> {
1559 validate_secret_len(secret.len())
1560}
1561
1562fn validate_secret_len(len: usize) -> Result<()> {
1564 if len > MAX_SECRET_LEN as usize {
1565 return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN));
1566 }
1567 Ok(())
1568}
1569
1570fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1571 result.map_err(map_turso_err)
1572}
1573
1574fn map_turso_err(err: turso::Error) -> Error {
1575 Error::PlatformFailure(Box::new(err))
1576}
1577
1578#[cfg(test)]
1579mod tests {
1580 use super::*;
1581
1582 fn new_store(path: &Path) -> Arc<DbKeyStore> {
1583 let config = DbKeyStoreConfig {
1584 path: path.to_path_buf(),
1585 ..Default::default()
1586 };
1587 DbKeyStore::new(config).expect("failed to create store")
1588 }
1589
1590 fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1591 store
1592 .build(service, user, None)
1593 .expect("failed to build entry")
1594 }
1595
1596 fn set_password(entry: &Entry, password: &str) -> Result<()> {
1597 entry.set_password(password)
1598 }
1599
1600 fn set_secret(entry: &Entry, secret: &[u8]) -> Result<()> {
1601 entry.set_secret(secret)
1602 }
1603
1604 fn get_password(entry: &Entry) -> Result<Zeroizing<String>> {
1605 Ok(Zeroizing::new(entry.get_password()?))
1606 }
1607
1608 #[test]
1610 fn create_store_creates_parent_dir() {
1611 let dir = tempfile::tempdir().expect("tempdir");
1612 let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1613 let parent = db_path.parent().expect("parent");
1614 assert!(!parent.exists());
1615
1616 let config = DbKeyStoreConfig {
1617 path: db_path.clone(),
1618 ..Default::default()
1619 };
1620 let store = DbKeyStore::new(config).expect("create store");
1621 assert!(parent.is_dir());
1622
1623 let entry = build_entry(&store, "demo", "alice");
1624 set_password(&entry, "dromomeryx").expect("set_password");
1625 }
1626
1627 #[test]
1629 fn set_password_then_search_finds_password() {
1630 let dir = tempfile::tempdir().expect("tempdir");
1631 let path = dir.path().join("keystore.db");
1632 let store = new_store(&path);
1633 let entry = build_entry(&store, "demo", "alice");
1634 set_password(&entry, "dromomeryx").expect("set_password");
1635
1636 let mut spec = HashMap::new();
1637 spec.insert("service", "demo");
1638 spec.insert("user", "alice");
1639 let results = store.search(&spec).expect("search");
1640 assert_eq!(results.len(), 1);
1641 let password = get_password(&results[0]).expect("get_password");
1642 assert_eq!(password.as_str(), "dromomeryx");
1643 }
1644
1645 #[test]
1647 fn comment_attributes_round_trip() {
1648 let dir = tempfile::tempdir().expect("tempdir");
1649 let path = dir.path().join("keystore.db");
1650 let store = new_store(&path);
1651 let entry = build_entry(&store, "demo", "alice");
1652 set_password(&entry, "dromomeryx").expect("set_password");
1653
1654 let update = HashMap::from([("comment", "note")]);
1655 entry.update_attributes(&update).expect("update_attributes");
1656 let attrs = entry.get_attributes().expect("get_attributes");
1657 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1658 assert!(attrs.contains_key("uuid"));
1659
1660 let mut spec = HashMap::new();
1661 spec.insert("service", "demo");
1662 spec.insert("user", "alice");
1663 spec.insert("comment", "note");
1664 let results = store.search(&spec).expect("search");
1665 assert_eq!(results.len(), 1);
1666
1667 let uuid = attrs.get("uuid").cloned().expect("get uuid");
1668 let mut spec = HashMap::new();
1669 spec.insert("service", "demo");
1670 spec.insert("user", "alice");
1671 spec.insert("uuid", uuid.as_str());
1672 let results = store.search(&spec).expect("search");
1673 assert_eq!(results.len(), 1);
1674 }
1675
1676 #[test]
1677 fn comment_with_password_round_trip() {
1678 let dir = tempfile::tempdir().expect("tempdir");
1679 let path = dir.path().join("keystore.db");
1680 let store = new_store(&path);
1681 let entry = build_entry(&store, "demo", "alice");
1682 set_password(&entry, "dromomeryx").expect("set_password");
1683
1684 let update = HashMap::from([("comment", "note")]);
1686 entry.update_attributes(&update).expect("update_attributes");
1687
1688 let mut spec = HashMap::new();
1690 spec.insert("service", "demo");
1691 spec.insert("user", "alice");
1692 spec.insert("comment", "note");
1693 let results = store.search(&spec).expect("search");
1694 assert_eq!(results.len(), 1);
1695
1696 let found = &results[0];
1697 let password = get_password(found).expect("password with comment");
1698 assert_eq!(password.as_str(), "dromomeryx");
1699 let attrs = found.get_attributes().expect("get_attributes");
1700 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1701 assert!(attrs.contains_key("uuid"));
1702 }
1703
1704 #[test]
1705 fn build_with_comment_modifier_sets_comment() -> Result<()> {
1706 let dir = tempfile::tempdir().expect("tempdir");
1707 let path = dir.path().join("keystore.db");
1708 let store = new_store(&path);
1709 let entry = store.build(
1710 "demo",
1711 "alice",
1712 Some(&HashMap::from([("comment", "initial")])),
1713 )?;
1714 set_password(&entry, "dromomeryx")?;
1715
1716 let attrs = entry.get_attributes()?;
1717 assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1718 Ok(())
1719 }
1720
1721 #[test]
1722 fn in_memory_store_round_trip() -> Result<()> {
1723 let config = DbKeyStoreConfig {
1724 vfs: Some("memory".to_string()),
1725 ..Default::default()
1726 };
1727 let store = DbKeyStore::new(config)?;
1728 let entry = build_entry(&store, "demo", "alice");
1729 set_password(&entry, "dromomeryx")?;
1730
1731 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1732 assert_eq!(results.len(), 1);
1733 let password = get_password(&results[0])?;
1734 assert_eq!(password.as_str(), "dromomeryx");
1735 Ok(())
1736 }
1737
1738 #[test]
1740 fn stores_separate_service_user_pairs() -> Result<()> {
1741 let dir = tempfile::tempdir().expect("tempdir");
1742 let path = dir.path().join("keystore.db");
1743 let store = new_store(&path);
1744
1745 let entry = build_entry(&store, "myapp", "user1");
1746 set_password(&entry, "pw1")?;
1747 let entry = build_entry(&store, "myapp", "user2");
1748 set_password(&entry, "pw2")?;
1749 let entry = build_entry(&store, "myapp", "user3");
1750 set_password(&entry, "pw3")?;
1751
1752 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1753 assert_eq!(results.len(), 1);
1754 let password = get_password(&results[0])?;
1755 assert_eq!(password.as_str(), "pw1");
1756
1757 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1758 assert_eq!(results.len(), 1);
1759 let password = get_password(&results[0])?;
1760 assert_eq!(password.as_str(), "pw2");
1761
1762 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1763 assert_eq!(results.len(), 1);
1764 let password = get_password(&results[0])?;
1765 assert_eq!(password.as_str(), "pw3");
1766 Ok(())
1767 }
1768
1769 #[test]
1771 fn search_regex() -> Result<()> {
1772 let dir = tempfile::tempdir().expect("tempdir");
1773 let path = dir.path().join("keystore.db");
1774 let store = new_store(&path);
1775
1776 let entry = build_entry(&store, "myapp", "user1");
1777 set_password(&entry, "pw1")?;
1778 let entry = build_entry(&store, "myapp", "user2");
1779 set_password(&entry, "pw2")?;
1780 let entry = build_entry(&store, "myapp", "user3");
1781 set_password(&entry, "pw3")?;
1782 let entry = build_entry(&store, "other-app", "user1");
1783 set_password(&entry, "pw4")?;
1784
1785 let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1787 assert_eq!(results.len(), 2, "search *app, user1");
1788
1789 let results = store.search(&HashMap::from([
1791 ("service", "myapp"),
1792 ("user", "user1|user2"),
1793 ]))?;
1794 assert_eq!(results.len(), 2, "search regex OR");
1795
1796 Ok(())
1797 }
1798
1799 #[test]
1801 fn search_partial() -> Result<()> {
1802 let dir = tempfile::tempdir().expect("tempdir");
1803 let path = dir.path().join("keystore.db");
1804 let store = new_store(&path);
1805
1806 let results = store.search(&HashMap::new())?;
1808 assert_eq!(results.len(), 0, "empty db, no results");
1809
1810 let entry = build_entry(&store, "myapp", "user1");
1811 set_password(&entry, "pw1")?;
1812 let entry = build_entry(&store, "other-app", "user1");
1813 set_password(&entry, "pw2")?;
1814
1815 let results = store.search(&HashMap::new())?;
1817 assert_eq!(results.len(), 2, "search, empty hashmap");
1818
1819 let results = store.search(&HashMap::from([("service", "myapp")]))?;
1821 assert_eq!(results.len(), 1, "search myapp");
1822
1823 let results = store.search(&HashMap::from([("user", "user1")]))?;
1825 assert_eq!(results.len(), 2, "search user1");
1826 Ok(())
1827 }
1828
1829 #[test]
1831 fn repeated_set_replaces_secret() {
1832 let dir = tempfile::tempdir().expect("tempdir");
1833 let path = dir.path().join("keystore.db");
1834 let store = new_store(&path);
1835 let entry = build_entry(&store, "demo", "alice");
1836 set_password(&entry, "first").expect("password set 1");
1837 set_secret(&entry, b"second").expect("password set 2");
1838
1839 let mut spec = HashMap::new();
1840 spec.insert("service", "demo");
1841 spec.insert("user", "alice");
1842 let results = store.search(&spec).expect("search");
1843 assert_eq!(results.len(), 1);
1844 let password = get_password(&results[0]).expect("get first password");
1845 assert_eq!(
1846 password.as_str(),
1847 "second",
1848 "second password overwrites first"
1849 );
1850 }
1851
1852 #[test]
1853 fn same_service_user_entries_share_credential() -> Result<()> {
1854 let dir = tempfile::tempdir().expect("tempdir");
1855 let path = dir.path().join("keystore.db");
1856 let store = new_store(&path);
1857 let entry1 = build_entry(&store, "demo", "alice");
1858 let entry2 = build_entry(&store, "demo", "alice");
1859
1860 set_password(&entry1, "first")?;
1861 let password = get_password(&entry2)?;
1862 assert_eq!(password.as_str(), "first");
1863
1864 set_password(&entry2, "second")?;
1865 let password = get_password(&entry1)?;
1866 assert_eq!(password.as_str(), "second");
1867 Ok(())
1868 }
1869
1870 #[test]
1872 fn remove_returns_no_entry() {
1873 let dir = tempfile::tempdir().expect("tempdir");
1874 let path = dir.path().join("keystore.db");
1875 let store = new_store(&path);
1876 let entry = build_entry(&store, "demo", "alice");
1877 set_password(&entry, "dromomeryx").expect("set password");
1878 entry.delete_credential().expect("delete credential");
1879 let err = entry.delete_credential().unwrap_err();
1880 assert!(matches!(err, Error::NoEntry));
1881 }
1882
1883 #[test]
1885 fn remove_clears_secret() {
1886 let dir = tempfile::tempdir().expect("tempdir");
1887 let path = dir.path().join("keystore.db");
1888 let store = new_store(&path);
1889 let entry = build_entry(&store, "service", "user");
1890 set_password(&entry, "dromomeryx").expect("set password");
1891 entry.delete_credential().expect("delete credential");
1892
1893 let mut spec = HashMap::new();
1894 spec.insert("service", "demo");
1895 spec.insert("user", "alice");
1896 let results = store.search(&spec).expect("search");
1897 assert!(results.is_empty());
1898 }
1899
1900 #[test]
1901 fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1902 let dir = tempfile::tempdir().expect("tempdir");
1903 let path = dir.path().join("keystore.db");
1904 let config = DbKeyStoreConfig {
1905 path: path.clone(),
1906 allow_ambiguity: true,
1907 ..Default::default()
1908 };
1909 let store = DbKeyStore::new(config)?;
1910 let uuid1 = new_uuid();
1911 let uuid2 = new_uuid();
1912 let entry1 = store.build(
1913 "demo",
1914 "alice",
1915 Some(&HashMap::from([
1916 ("uuid", uuid1.as_str()),
1917 ("comment", "one"),
1918 ])),
1919 )?;
1920 let entry2 = store.build(
1921 "demo",
1922 "alice",
1923 Some(&HashMap::from([
1924 ("uuid", uuid2.as_str()),
1925 ("comment", "two"),
1926 ])),
1927 )?;
1928 set_password(&entry1, "first")?;
1929 set_password(&entry2, "second")?;
1930
1931 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1932 assert_eq!(results.len(), 2);
1933
1934 let entry3 = build_entry(&store, "demo", "alice");
1935 let err = entry3.get_password().unwrap_err();
1936 assert!(matches!(err, Error::Ambiguous(_)));
1937 Ok(())
1938 }
1939
1940 #[test]
1941 fn duplicate_uuid_across_service_user_is_scoped() -> Result<()> {
1942 let dir = tempfile::tempdir().expect("tempdir");
1943 let path = dir.path().join("keystore.db");
1944 let config = DbKeyStoreConfig {
1945 path: path.clone(),
1946 allow_ambiguity: true,
1947 ..Default::default()
1948 };
1949 let store = DbKeyStore::new(config)?;
1950 let uuid = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
1951 let entry1 = store.build(
1952 "service-a",
1953 "user-a",
1954 Some(&HashMap::from([("uuid", uuid)])),
1955 )?;
1956 let entry2 = store.build(
1957 "service-b",
1958 "user-b",
1959 Some(&HashMap::from([("uuid", uuid)])),
1960 )?;
1961 set_password(&entry1, "pw1")?;
1962 set_password(&entry2, "pw2")?;
1963
1964 entry1.update_attributes(&HashMap::from([("comment", "note1")]))?;
1965 let attrs1 = entry1.get_attributes()?;
1966 assert_eq!(attrs1.get("comment"), Some(&"note1".to_string()));
1967
1968 let attrs2 = entry2.get_attributes()?;
1969 assert!(!attrs2.contains_key("comment"));
1970
1971 entry1.delete_credential()?;
1972 let pw2 = get_password(&entry2)?;
1973 assert_eq!(pw2.as_str(), "pw2");
1974 Ok(())
1975 }
1976
1977 #[test]
1978 fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1979 let dir = tempfile::tempdir().expect("tempdir");
1980 let path = dir.path().join("keystore.db");
1981 let store = new_store(&path);
1982 let uuid1 = new_uuid();
1983 let uuid2 = new_uuid();
1984 let entry1 = store.build(
1985 "demo",
1986 "alice",
1987 Some(&HashMap::from([("uuid", uuid1.as_str())])),
1988 )?;
1989 let entry2 = store.build(
1990 "demo",
1991 "alice",
1992 Some(&HashMap::from([("uuid", uuid2.as_str())])),
1993 )?;
1994
1995 set_password(&entry1, "first")?;
1996 let err = set_password(&entry2, "second").unwrap_err();
1997 assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1998 Ok(())
1999 }
2000
2001 #[test]
2002 fn impl_debug() -> Result<()> {
2003 let dir = tempfile::tempdir().expect("tempdir");
2004
2005 let path = dir.path().join("keystore1.db");
2006 let store = new_store(&path);
2007 eprintln!("basic: {store:?}");
2008
2009 let path = dir.path().join("keystore2.db");
2010 let config = DbKeyStoreConfig {
2011 path: path.clone(),
2012 encryption_opts: Some(EncryptionOpts::new(
2013 "aes256gcm",
2014 "0000000011111111222222223333333344444444555555556666666677777777",
2015 )?),
2016 ..Default::default()
2017 };
2018 let store = DbKeyStore::new(config)?;
2019 eprintln!("with_enc: {store:?}");
2020
2021 let config = DbKeyStoreConfig {
2022 vfs: Some("memory".to_string()),
2023 ..Default::default()
2024 };
2025 let store = DbKeyStore::new(config)?;
2026 eprintln!("memory: {store:?}");
2027 Ok(())
2028 }
2029
2030 #[test]
2031 fn uuid_v7_strings_are_lexicographically_increasing() {
2032 let mut uuids = Vec::new();
2033 for _ in 0..8 {
2034 uuids.push(new_uuid());
2035 }
2036 for pair in uuids.windows(2) {
2037 assert!(
2038 pair[0] < pair[1],
2039 "uuid v7 strings should be lexicographically increasing"
2040 );
2041 }
2042 }
2043}