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 = open_db_with_retry(&self.path, encryption_opts.as_ref(), vfs.as_deref())?;
548 let conn = retry_turso_locking(|| db.connect())?;
549 configure_connection(&conn)?;
550 Ok(DbSession { db: Some(db), conn })
551 }
552 }
553 }
554}
555
556impl DbKeyCredential {
557 async fn insert_credential(
558 &self,
559 conn: &Connection,
560 uuid: &str,
561 secret: Value,
562 comment: Value,
563 ) -> Result<()> {
564 conn.execute(
565 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
566 (
567 self.id.service.as_str(),
568 self.id.user.as_str(),
569 uuid,
570 secret,
571 comment,
572 ),
573 )
574 .await
575 .map_err(map_turso_err)?;
576 Ok(())
577 }
578}
579
580impl CredentialStoreApi for DbKeyStore {
581 fn vendor(&self) -> String {
582 String::from("DbKeyStore, https://crates.io/crates/db-keystore")
583 }
584
585 fn id(&self) -> String {
586 self.inner.id.clone()
587 }
588
589 fn build(
593 &self,
594 service: &str,
595 user: &str,
596 modifiers: Option<&HashMap<&str, &str>>,
597 ) -> Result<Entry> {
598 validate_service_user(service, user)?;
599 let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
600 let credential = DbKeyCredential {
601 inner: Arc::clone(&self.inner),
602 id: CredId {
603 service: service.to_string(),
604 user: user.to_string(),
605 },
606 uuid: mods
607 .get("uuid")
608 .map(|value| normalize_uuid_input(value))
609 .transpose()?,
610 comment: mods.get("comment").cloned(),
611 };
612 Ok(Entry::new_with_credential(Arc::new(credential)))
613 }
614
615 fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
622 let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
623 let service_re = Regex::new(spec.get("service").map_or("", String::as_str))
624 .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
625 let user_re = Regex::new(spec.get("user").map_or("", String::as_str))
626 .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
627 let comment_re = Regex::new(spec.get("comment").map_or("", String::as_str))
628 .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
629 let uuid_spec = match spec.get("uuid") {
630 Some(value) => Some(normalize_uuid_input(value)?),
631 None => None,
632 };
633 let uuid_re = Regex::new(uuid_spec.as_deref().unwrap_or(""))
634 .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
635 let conn = self.inner.connect()?;
636 let rows = map_turso(block_on(query_all_credentials(&conn)))?;
637 let mut entries = Vec::new();
638 let comment_filter = spec.get("comment").cloned();
639 let filter_comment = spec.contains_key("comment");
640 let filter_comment_empty = comment_filter.as_deref().is_some_and(str::is_empty);
641 for (id, uuid, comment) in rows {
642 if !service_re.is_match(id.service.as_str()) {
643 continue;
644 }
645 if !user_re.is_match(id.user.as_str()) {
646 continue;
647 }
648 if !uuid_re.is_match(uuid.as_str()) {
649 continue;
650 }
651 if filter_comment {
652 if filter_comment_empty {
653 if comment.as_deref().is_some_and(|value| !value.is_empty()) {
655 continue;
656 }
657 } else {
658 match comment.as_ref() {
660 Some(text) if comment_re.is_match(text.as_str()) => {}
661 _ => continue,
662 }
663 }
664 }
665 let credential = DbKeyCredential {
666 inner: Arc::clone(&self.inner),
667 id,
668 uuid: Some(uuid),
669 comment: None,
670 };
671 entries.push(Entry::new_with_credential(Arc::new(credential)));
672 }
673 Ok(entries)
674 }
675
676 fn as_any(&self) -> &dyn std::any::Any {
677 self
678 }
679
680 fn persistence(&self) -> CredentialPersistence {
681 CredentialPersistence::UntilDelete
682 }
683
684 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
685 fmt::Debug::fmt(self, f)
686 }
687}
688
689impl DbKeyCredential {
690 fn get_secret_zeroizing(&self) -> Result<Zeroizing<Vec<u8>>> {
691 validate_service_user(&self.id.service, &self.id.user)?;
692 let conn = self.inner.connect()?;
693 if let Some(uuid) = &self.uuid {
694 let match_result = map_turso(block_on(fetch_secret_by_key(&conn, &self.id, uuid)))?;
695 match match_result {
696 LookupResult::None => Err(Error::NoEntry),
697 LookupResult::One(secret) => Ok(secret),
698 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
699 &Arc::clone(&self.inner),
700 &self.id,
701 uuids,
702 ))),
703 }
704 } else {
705 let match_result = map_turso(block_on(fetch_secret_by_id(&conn, &self.id)))?;
706 match match_result {
707 LookupResult::None => Err(Error::NoEntry),
708 LookupResult::One(secret) => Ok(secret),
709 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
710 &Arc::clone(&self.inner),
711 &self.id,
712 uuids,
713 ))),
714 }
715 }
716 }
717
718 async fn set_secret_unambiguous(
719 &self,
720 conn: &Connection,
721 make_secret_value: &dyn Fn() -> Value,
722 make_comment_value: &dyn Fn() -> Value,
723 ) -> Result<()> {
724 let uuid = new_uuid();
725 let _ = conn.execute(
726 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
727 ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
728 (
729 self.id.service.as_str(),
730 self.id.user.as_str(),
731 uuid.as_str(),
732 make_secret_value(),
733 make_comment_value(),
734 ),
735 )
736 .await.map_err(map_turso_err)?;
737 Ok(())
738 }
739
740 async fn set_secret_with_uuid(
741 &self,
742 conn: &Connection,
743 uuid: &str,
744 make_secret_value: &dyn Fn() -> Value,
745 make_comment_value: &dyn Fn() -> Value,
746 ) -> Result<()> {
747 let updated = conn
748 .execute(
749 "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
750 (
751 make_secret_value(),
752 self.id.service.as_str(),
753 self.id.user.as_str(),
754 uuid,
755 ),
756 )
757 .await
758 .map_err(map_turso_err)?;
759 if updated > 0 {
760 return Ok(());
761 }
762 if !self.inner.allow_ambiguity {
763 let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
764 match uuids.len() {
765 0 => {}
766 1 => {
767 if uuids[0] != uuid {
768 return Err(Error::Invalid(
769 "uuid".to_string(),
770 "can't create ambiguous credential for service/user".to_string(),
771 ));
772 }
773 }
774 _ => {
775 return Err(Error::PlatformFailure(format!(
777 "Database is in an invalid state: ambiguity not allowed, but multiple entries found for {:?}",
778 self.id
779 ).into()));
780 }
781 }
782 }
783 self.insert_credential(conn, uuid, make_secret_value(), make_comment_value())
784 .await?;
785 Ok(())
786 }
787
788 async fn set_secret_without_uuid(
789 &self,
790 conn: &Connection,
791 make_secret_value: &dyn Fn() -> Value,
792 make_comment_value: &dyn Fn() -> Value,
793 ) -> Result<()> {
794 let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
795 match uuids.len() {
796 0 => {
797 let uuid = new_uuid();
798 self.insert_credential(
799 conn,
800 uuid.as_str(),
801 make_secret_value(),
802 make_comment_value(),
803 )
804 .await?;
805 Ok(())
806 }
807 1 => {
808 conn.execute(
809 "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
810 (
811 make_secret_value(),
812 self.id.service.as_str(),
813 self.id.user.as_str(),
814 uuids[0].as_str(),
815 ),
816 )
817 .await
818 .map_err(map_turso_err)?;
819 Ok(())
820 }
821 _ => Err(Error::Ambiguous(ambiguous_entries(
822 &self.inner,
823 &self.id,
824 uuids,
825 ))),
826 }
827 }
828
829 async fn set_secret_in_tx(
830 &self,
831 conn: &Connection,
832 make_secret_value: &dyn Fn() -> Value,
833 make_comment_value: &dyn Fn() -> Value,
834 ) -> Result<()> {
835 if let Some(uuid) = &self.uuid {
836 self.set_secret_with_uuid(conn, uuid.as_str(), make_secret_value, make_comment_value)
837 .await
838 } else {
839 self.set_secret_without_uuid(conn, make_secret_value, make_comment_value)
840 .await
841 }
842 }
843
844 async fn finish_tx(conn: &Connection, result: Result<()>) -> Result<()> {
845 match result {
846 Ok(()) => {
847 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
848 Ok(())
849 }
850 Err(err) => {
851 if let Err(e2) = conn.execute("ROLLBACK", ()).await {
852 log::error!(
853 "While handling set_secret error ({err:?}). attempted ROLLBACK, which encountered secondary error: {e2:?}"
854 );
855 }
856 Err(err)
857 }
858 }
859 }
860}
861
862impl CredentialApi for DbKeyCredential {
863 fn set_secret(&self, secret: &[u8]) -> Result<()> {
864 validate_service_user(&self.id.service, &self.id.user)?;
865 validate_secret(secret)?;
866 let make_secret_value = || Value::Blob(secret.to_vec());
867 let make_comment_value = || comment_value(self.comment.as_ref());
868 let conn = self.inner.connect()?;
869 if self.uuid.is_none() && !self.inner.allow_ambiguity {
870 return block_on(self.set_secret_unambiguous(
871 &conn,
872 &make_secret_value,
873 &make_comment_value,
874 ));
875 }
876 block_on(async {
877 conn.execute("BEGIN IMMEDIATE", ())
878 .await
879 .map_err(map_turso_err)?;
880 let result = self
881 .set_secret_in_tx(&conn, &make_secret_value, &make_comment_value)
882 .await;
883 Self::finish_tx(&conn, result).await
884 })
885 }
886
887 fn get_secret(&self) -> Result<Vec<u8>> {
888 let secret = self.get_secret_zeroizing()?;
889 Ok(take_zeroizing_vec(secret))
890 }
891
892 fn get_attributes(&self) -> Result<HashMap<String, String>> {
893 validate_service_user(&self.id.service, &self.id.user)?;
894 let conn = self.inner.connect()?;
895 if let Some(uuid) = &self.uuid {
896 let match_result = map_turso(block_on(fetch_comment_by_key(&conn, &self.id, uuid)))?;
897 match match_result {
898 LookupResult::None => Err(Error::NoEntry),
899 LookupResult::One(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
900 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
901 &self.inner,
902 &self.id,
903 uuids,
904 ))),
905 }
906 } else {
907 let match_result = map_turso(block_on(fetch_comment_by_id(&conn, &self.id)))?;
908 match match_result {
909 LookupResult::None => Err(Error::NoEntry),
910 LookupResult::One(row) => Ok(attributes_for_uuid(row.uuid.as_str(), row.comment)),
911 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
912 &self.inner,
913 &self.id,
914 uuids,
915 ))),
916 }
917 }
918 }
919
920 fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
921 parse_attributes(&["comment"], Some(attrs))?;
922 let comment = attrs.get("comment").map(ToString::to_string);
923 let has_comment = attrs.contains_key("comment");
924 if !has_comment {
925 self.get_attributes()?;
926 return Ok(());
927 }
928 let comment = comment.filter(|value| !value.is_empty());
929 let make_comment_value = || comment_value(comment.as_ref());
930 let conn = self.inner.connect()?;
931 block_on(async {
932 conn.execute("BEGIN IMMEDIATE", ())
933 .await
934 .map_err(map_turso_err)?;
935 let result = match &self.uuid {
936 Some(uuid) => {
937 let uuids = fetch_uuids_by_key(&conn, &self.id, uuid)
938 .await
939 .map_err(map_turso_err)?;
940 match uuids.len() {
941 0 => Err(Error::NoEntry),
942 1 => {
943 conn.execute(
944 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
945 (
946 make_comment_value(),
947 self.id.service.as_str(),
948 self.id.user.as_str(),
949 uuid.as_str(),
950 ),
951 )
952 .await
953 .map_err(map_turso_err)?;
954 Ok(())
955 }
956 _ => Err(Error::Ambiguous(ambiguous_entries(
957 &self.inner,
958 &self.id,
959 uuids,
960 ))),
961 }
962 }
963 None if self.inner.allow_ambiguity => {
964 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
965 match uuids.len() {
966 0 => Err(Error::NoEntry),
967 1 => {
968 conn.execute(
969 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
970 (
971 make_comment_value(),
972 self.id.service.as_str(),
973 self.id.user.as_str(),
974 uuids[0].as_str(),
975 ),
976 )
977 .await
978 .map_err(map_turso_err)?;
979 Ok(())
980 }
981 _ => Err(Error::Ambiguous(ambiguous_entries(
982 &self.inner,
983 &self.id,
984 uuids,
985 ))),
986 }
987 }
988 None => {
989 let updated = conn
990 .execute(
991 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
992 (
993 make_comment_value(),
994 self.id.service.as_str(),
995 self.id.user.as_str(),
996 ),
997 )
998 .await
999 .map_err(map_turso_err)?;
1000 if updated == 0 {
1001 Err(Error::NoEntry)
1002 } else {
1003 Ok(())
1004 }
1005 }
1006 };
1007 match result {
1008 Ok(()) => {
1009 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
1010 Ok(())
1011 }
1012 Err(err) => {
1013 let _ = conn.execute("ROLLBACK", ()).await;
1015 Err(err)
1016 }
1017 }
1018 })
1019 }
1020
1021 fn delete_credential(&self) -> Result<()> {
1022 validate_service_user(&self.id.service, &self.id.user)?;
1023 let conn = self.inner.connect()?;
1024 if let Some(uuid) = &self.uuid {
1025 let deleted = map_turso(block_on(conn.execute(
1026 "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1027 (
1028 self.id.service.as_str(),
1029 self.id.user.as_str(),
1030 uuid.as_str(),
1031 ),
1032 )))?;
1033 if deleted == 0 {
1034 Err(Error::NoEntry)
1035 } else {
1036 Ok(())
1037 }
1038 } else {
1039 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
1040 match uuids.len() {
1041 0 => Err(Error::NoEntry),
1042 1 => {
1043 map_turso(block_on(conn.execute(
1044 "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1045 (
1046 self.id.service.as_str(),
1047 self.id.user.as_str(),
1048 uuids[0].as_str(),
1049 ),
1050 )))?;
1051 Ok(())
1052 }
1053 _ => Err(Error::Ambiguous(ambiguous_entries(
1054 &self.inner,
1055 &self.id,
1056 uuids,
1057 ))),
1058 }
1059 }
1060 }
1061
1062 fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
1063 validate_service_user(&self.id.service, &self.id.user)?;
1064 let conn = self.inner.connect()?;
1065 if let Some(uuid) = &self.uuid {
1066 let uuids = map_turso(block_on(fetch_uuids_by_key(&conn, &self.id, uuid)))?;
1067 match uuids.len() {
1068 0 => Err(Error::NoEntry),
1069 1 => Ok(Some(Arc::new(DbKeyCredential {
1070 inner: Arc::clone(&self.inner),
1071 id: self.id.clone(),
1072 uuid: Some(uuid.clone()),
1073 comment: None,
1074 }))),
1075 _ => Err(Error::Ambiguous(ambiguous_entries(
1076 &self.inner,
1077 &self.id,
1078 uuids,
1079 ))),
1080 }
1081 } else {
1082 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
1083 match uuids.len() {
1084 0 => Err(Error::NoEntry),
1085 1 => Ok(Some(Arc::new(DbKeyCredential {
1086 inner: Arc::clone(&self.inner),
1087 id: self.id.clone(),
1088 uuid: Some(uuids[0].clone()),
1089 comment: None,
1090 }))),
1091 _ => Err(Error::Ambiguous(ambiguous_entries(
1092 &self.inner,
1093 &self.id,
1094 uuids,
1095 ))),
1096 }
1097 }
1098 }
1099
1100 fn get_specifiers(&self) -> Option<(String, String)> {
1101 Some((self.id.service.clone(), self.id.user.clone()))
1102 }
1103
1104 fn as_any(&self) -> &dyn std::any::Any {
1105 self
1106 }
1107
1108 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1109 fmt::Debug::fmt(self, f)
1110 }
1111}
1112
1113fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
1114 map_turso(block_on(conn.execute(
1115 "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
1116 (),
1117 )))?;
1118 map_turso(block_on(conn.execute(
1119 "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
1120 (),
1121 )))?;
1122 ensure_schema_version(conn)?;
1123 if !allow_ambiguity {
1124 map_turso(block_on(conn.execute(
1126 "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
1127 (),
1128 )))?;
1129 } else if index_always {
1130 map_turso(block_on(conn.execute(
1135 "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
1136 (),
1137 )))?;
1138 }
1139 Ok(())
1140}
1141
1142fn ensure_schema_version(conn: &Connection) -> Result<()> {
1143 map_turso(block_on(async {
1144 let mut rows = conn
1145 .query(
1146 "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
1147 (),
1148 )
1149 .await?;
1150 if let Some(row) = rows.next().await? {
1151 let value = value_to_string(row.get_value(0)?, "schema_version")?;
1152 let version = value.parse::<u32>().map_err(|_| {
1153 turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
1154 })?;
1155 if version != SCHEMA_VERSION {
1156 return Err(turso::Error::ConversionFailure(format!(
1157 "unsupported schema version: {version}"
1158 )));
1159 }
1160 } else {
1161 conn.execute(
1162 "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
1163 (SCHEMA_VERSION.to_string(),),
1164 )
1165 .await?;
1166 }
1167 Ok(())
1168 }))
1169}
1170
1171async fn query_all_credentials(
1172 conn: &Connection,
1173) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
1174 let mut rows = conn
1175 .query("SELECT service, user, uuid, comment FROM credentials", ())
1176 .await?;
1177 let mut results = Vec::new();
1178 while let Some(row) = rows.next().await? {
1179 let service = value_to_string(row.get_value(0)?, "service")?;
1180 let user = value_to_string(row.get_value(1)?, "user")?;
1181 let uuid = value_to_string(row.get_value(2)?, "uuid")?;
1182 let comment = value_to_option_string(row.get_value(3)?, "comment")?;
1183 results.push((CredId { service, user }, uuid, comment));
1184 }
1185 Ok(results)
1186}
1187
1188async fn schema_has_unique_service_user(conn: &Connection) -> turso::Result<bool> {
1191 let mut rows = conn
1192 .query(
1193 "SELECT sql FROM sqlite_master \
1194 WHERE (type = 'index' AND tbl_name = 'credentials') \
1195 OR (type = 'table' AND name = 'credentials') \
1196 AND sql IS NOT NULL",
1197 (),
1198 )
1199 .await?;
1200 while let Some(row) = rows.next().await? {
1201 match row.get_value(0)? {
1202 Value::Text(sql) if is_unique_service_user_sql(sql.as_str()) => return Ok(true),
1203 _ => {}
1204 }
1205 }
1206 Ok(false)
1207}
1208
1209fn is_unique_service_user_sql(sql: &str) -> bool {
1212 let normalized: String = sql
1213 .chars()
1214 .filter(|c| !c.is_whitespace() && *c != '"' && *c != '`')
1215 .flat_map(char::to_lowercase)
1216 .collect();
1217 normalized.contains("unique") && normalized.contains("(service,user)")
1218}
1219
1220async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
1221 let mut rows = conn
1222 .query(
1223 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
1224 (id.service.as_str(), id.user.as_str()),
1225 )
1226 .await?;
1227 let mut uuids = Vec::new();
1228 while let Some(row) = rows.next().await? {
1229 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1230 uuids.push(uuid);
1231 }
1232 Ok(uuids)
1233}
1234
1235async fn fetch_secret_by_key(
1236 conn: &Connection,
1237 id: &CredId,
1238 uuid: &str,
1239) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1240 let mut rows = conn
1241 .query(
1242 "SELECT secret FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1243 (id.service.as_str(), id.user.as_str(), uuid),
1244 )
1245 .await?;
1246 let mut secrets = Vec::new();
1247 while let Some(row) = rows.next().await? {
1248 let secret = value_to_secret(row.get_value(0)?, "secret")?;
1249 secrets.push(secret);
1250 }
1251 match secrets.len() {
1252 0 => Ok(LookupResult::None),
1253 1 => Ok(LookupResult::One(
1254 secrets.into_iter().next().expect("secret for single match"),
1255 )),
1256 _ => Ok(LookupResult::Ambiguous(vec![
1257 uuid.to_string();
1258 secrets.len()
1259 ])),
1260 }
1261}
1262
1263async fn fetch_comment_by_key(
1264 conn: &Connection,
1265 id: &CredId,
1266 uuid: &str,
1267) -> turso::Result<LookupResult<Option<String>>> {
1268 let mut rows = conn
1269 .query(
1270 "SELECT comment FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1271 (id.service.as_str(), id.user.as_str(), uuid),
1272 )
1273 .await?;
1274 let mut comments = Vec::new();
1275 while let Some(row) = rows.next().await? {
1276 let comment = value_to_option_string(row.get_value(0)?, "comment")?;
1277 comments.push(comment);
1278 }
1279 match comments.len() {
1280 0 => Ok(LookupResult::None),
1281 1 => Ok(LookupResult::One(
1282 comments
1283 .into_iter()
1284 .next()
1285 .expect("comment for single match"),
1286 )),
1287 _ => Ok(LookupResult::Ambiguous(vec![
1288 uuid.to_string();
1289 comments.len()
1290 ])),
1291 }
1292}
1293
1294async fn fetch_secret_by_id(
1295 conn: &Connection,
1296 id: &CredId,
1297) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1298 let uuids = fetch_uuids(conn, id).await?;
1299 match uuids.len() {
1300 0 => Ok(LookupResult::None),
1301 1 => fetch_secret_by_key(conn, id, uuids[0].as_str()).await,
1302 _ => Ok(LookupResult::Ambiguous(uuids)),
1303 }
1304}
1305
1306async fn fetch_comment_by_id(
1307 conn: &Connection,
1308 id: &CredId,
1309) -> turso::Result<LookupResult<CommentRow>> {
1310 let uuids = fetch_uuids(conn, id).await?;
1311 match uuids.len() {
1312 0 => Ok(LookupResult::None),
1313 1 => {
1314 let uuid = uuids.into_iter().next().expect("uuid");
1315 match fetch_comment_by_key(conn, id, uuid.as_str()).await? {
1316 LookupResult::None => Ok(LookupResult::None),
1317 LookupResult::One(comment) => Ok(LookupResult::One(CommentRow { uuid, comment })),
1318 LookupResult::Ambiguous(uuids) => Ok(LookupResult::Ambiguous(uuids)),
1319 }
1320 }
1321 _ => Ok(LookupResult::Ambiguous(uuids)),
1322 }
1323}
1324
1325async fn fetch_uuids_by_key(
1326 conn: &Connection,
1327 id: &CredId,
1328 uuid: &str,
1329) -> turso::Result<Vec<String>> {
1330 let mut rows = conn
1331 .query(
1332 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1333 (id.service.as_str(), id.user.as_str(), uuid),
1334 )
1335 .await?;
1336 let mut uuids = Vec::new();
1337 while let Some(row) = rows.next().await? {
1338 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1339 uuids.push(uuid);
1340 }
1341 Ok(uuids)
1342}
1343
1344fn ambiguous_entries(inner: &Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
1345 uuids
1346 .into_iter()
1347 .map(|uuid| {
1348 Entry::new_with_credential(Arc::new(DbKeyCredential {
1349 inner: Arc::clone(inner),
1350 id: id.clone(),
1351 uuid: Some(uuid),
1352 comment: None,
1353 }))
1354 })
1355 .collect()
1356}
1357
1358fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
1359 let mut attrs = HashMap::new();
1360 attrs.insert("uuid".to_string(), uuid.to_string());
1361 if let Some(comment) = comment {
1362 attrs.insert("comment".to_string(), comment);
1363 }
1364 attrs
1365}
1366
1367fn comment_value(comment: Option<&String>) -> Value {
1368 match comment {
1369 Some(value) if !value.is_empty() => Value::Text(value.clone()),
1370 _ => Value::Null,
1371 }
1372}
1373
1374fn normalize_uuid_input(value: &str) -> Result<String> {
1375 let lower = value.to_ascii_lowercase();
1376 let uuid = uuid::Uuid::try_parse(&lower)
1377 .map_err(|_| Error::Invalid("uuid".to_string(), "invalid uuid format".to_string()))?;
1378 if uuid.to_string() != lower {
1379 return Err(Error::Invalid(
1380 "uuid".to_string(),
1381 "invalid uuid format".to_string(),
1382 ));
1383 }
1384 Ok(lower)
1385}
1386
1387fn take_zeroizing_vec(mut value: Zeroizing<Vec<u8>>) -> Vec<u8> {
1388 std::mem::take(&mut *value)
1389}
1390
1391fn configure_connection(conn: &Connection) -> Result<()> {
1395 map_turso(block_on(async {
1396 let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
1397 let _ = rows.next().await?;
1398 let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
1399 conn.execute(busy_stmt.as_str(), ()).await?;
1400 Ok(())
1401 }))
1402}
1403
1404fn open_db_with_retry(
1406 path_str: &str,
1407 encryption_opts: Option<&EncryptionOpts>,
1408 vfs: Option<&str>,
1409) -> Result<Database> {
1410 let mut retries = OPEN_LOCK_RETRIES;
1411 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1412 loop {
1413 let mut builder = Builder::new_local(path_str);
1414 if let Some(opts) = encryption_opts {
1415 builder = builder
1417 .experimental_encryption(true)
1418 .with_encryption(turso_encryption_opts(opts));
1419 }
1420 if let Some(vfs) = vfs {
1421 builder = builder.with_io(vfs.to_string());
1422 }
1423 match block_on(builder.build()) {
1424 Ok(db) => return Ok(db),
1425 Err(err) => {
1426 check_decryption_error(&err)?;
1427 if retries == 0 || !is_turso_locking_error(&err) {
1428 return Err(map_turso_err(err));
1429 }
1430 retries -= 1;
1431 let nanos = SystemTime::now()
1432 .duration_since(UNIX_EPOCH)
1433 .unwrap_or_default()
1434 .subsec_nanos();
1435 let jitter = u64::from(nanos % 20);
1436 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1437 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1438 }
1439 }
1440 }
1441}
1442
1443fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
1444 let mut retries = OPEN_LOCK_RETRIES;
1445 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1446 loop {
1447 match op() {
1448 Ok(value) => return Ok(value),
1449 Err(err) => {
1450 if retries == 0 || !is_turso_locking_error(&err) {
1451 return Err(map_turso_err(err));
1452 }
1453 retries -= 1;
1454 let nanos = SystemTime::now()
1455 .duration_since(UNIX_EPOCH)
1456 .unwrap_or_default()
1457 .subsec_nanos();
1458 let jitter = u64::from(nanos % 20);
1459 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1460 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1461 }
1462 }
1463 }
1464}
1465
1466fn is_turso_locking_error(err: &turso::Error) -> bool {
1467 let text = err.to_string().to_lowercase();
1468 text.contains("locking error")
1469 || text.contains("file is locked")
1470 || text.contains("database is locked")
1471 || text.contains("database is busy")
1472 || text.contains("sqlite_busy")
1473 || text.contains("sqlite_locked")
1474}
1475
1476fn check_decryption_error(err: &turso::Error) -> Result<()> {
1477 let text = err.to_string();
1478 if text.starts_with("Decryption failed") {
1479 return Err(keyring_core::Error::NoStorageAccess(Box::new(
1480 turso::Error::Error(format!("Invalid encryption key or cipher. {text}")),
1481 )));
1482 }
1483 Ok(())
1484}
1485
1486fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
1487 match value {
1488 Value::Text(text) => Ok(text),
1489 Value::Blob(blob) => String::from_utf8(blob)
1490 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1491 other => Err(turso::Error::ConversionFailure(format!(
1492 "unexpected value for {field}: {other:?}"
1493 ))),
1494 }
1495}
1496
1497fn value_to_secret(value: Value, field: &str) -> turso::Result<Zeroizing<Vec<u8>>> {
1498 match value {
1499 Value::Blob(blob) => Ok(Zeroizing::new(blob)),
1500 Value::Text(text) => Ok(Zeroizing::new(text.into_bytes())),
1501 other => Err(turso::Error::ConversionFailure(format!(
1502 "unexpected value for {field}: {other:?}"
1503 ))),
1504 }
1505}
1506
1507fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1508 match value {
1509 Value::Null => Ok(None),
1510 Value::Text(text) => Ok(Some(text)),
1511 Value::Blob(blob) => String::from_utf8(blob)
1512 .map(Some)
1513 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1514 other => Err(turso::Error::ConversionFailure(format!(
1515 "unexpected value for {field}: {other:?}"
1516 ))),
1517 }
1518}
1519
1520fn ensure_parent_dir(path: &Path) -> Result<()> {
1521 let parent = path
1522 .parent()
1523 .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1524 if parent.as_os_str().is_empty() {
1525 return Ok(());
1526 }
1527 std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1528}
1529
1530fn validate_service_user(service: &str, user: &str) -> Result<()> {
1532 if service.is_empty() {
1533 return Err(Error::Invalid(
1534 "service".to_string(),
1535 "service is empty".to_string(),
1536 ));
1537 }
1538 if user.is_empty() {
1539 return Err(Error::Invalid(
1540 "user".to_string(),
1541 "user is empty".to_string(),
1542 ));
1543 }
1544 if service.len() > MAX_NAME_LEN as usize {
1545 return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN));
1546 }
1547 if user.len() > MAX_NAME_LEN as usize {
1548 return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN));
1549 }
1550 Ok(())
1551}
1552
1553fn validate_secret(secret: &[u8]) -> Result<()> {
1555 validate_secret_len(secret.len())
1556}
1557
1558fn validate_secret_len(len: usize) -> Result<()> {
1560 if len > MAX_SECRET_LEN as usize {
1561 return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN));
1562 }
1563 Ok(())
1564}
1565
1566fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1567 result.map_err(map_turso_err)
1568}
1569
1570fn map_turso_err(err: turso::Error) -> Error {
1571 Error::PlatformFailure(Box::new(err))
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576 use super::*;
1577
1578 fn new_store(path: &Path) -> Arc<DbKeyStore> {
1579 let config = DbKeyStoreConfig {
1580 path: path.to_path_buf(),
1581 ..Default::default()
1582 };
1583 DbKeyStore::new(config).expect("failed to create store")
1584 }
1585
1586 fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1587 store
1588 .build(service, user, None)
1589 .expect("failed to build entry")
1590 }
1591
1592 fn set_password(entry: &Entry, password: &str) -> Result<()> {
1593 entry.set_password(password)
1594 }
1595
1596 fn set_secret(entry: &Entry, secret: &[u8]) -> Result<()> {
1597 entry.set_secret(secret)
1598 }
1599
1600 fn get_password(entry: &Entry) -> Result<Zeroizing<String>> {
1601 Ok(Zeroizing::new(entry.get_password()?))
1602 }
1603
1604 #[test]
1606 fn create_store_creates_parent_dir() {
1607 let dir = tempfile::tempdir().expect("tempdir");
1608 let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1609 let parent = db_path.parent().expect("parent");
1610 assert!(!parent.exists());
1611
1612 let config = DbKeyStoreConfig {
1613 path: db_path.clone(),
1614 ..Default::default()
1615 };
1616 let store = DbKeyStore::new(config).expect("create store");
1617 assert!(parent.is_dir());
1618
1619 let entry = build_entry(&store, "demo", "alice");
1620 set_password(&entry, "dromomeryx").expect("set_password");
1621 }
1622
1623 #[test]
1625 fn set_password_then_search_finds_password() {
1626 let dir = tempfile::tempdir().expect("tempdir");
1627 let path = dir.path().join("keystore.db");
1628 let store = new_store(&path);
1629 let entry = build_entry(&store, "demo", "alice");
1630 set_password(&entry, "dromomeryx").expect("set_password");
1631
1632 let mut spec = HashMap::new();
1633 spec.insert("service", "demo");
1634 spec.insert("user", "alice");
1635 let results = store.search(&spec).expect("search");
1636 assert_eq!(results.len(), 1);
1637 let password = get_password(&results[0]).expect("get_password");
1638 assert_eq!(password.as_str(), "dromomeryx");
1639 }
1640
1641 #[test]
1643 fn comment_attributes_round_trip() {
1644 let dir = tempfile::tempdir().expect("tempdir");
1645 let path = dir.path().join("keystore.db");
1646 let store = new_store(&path);
1647 let entry = build_entry(&store, "demo", "alice");
1648 set_password(&entry, "dromomeryx").expect("set_password");
1649
1650 let update = HashMap::from([("comment", "note")]);
1651 entry.update_attributes(&update).expect("update_attributes");
1652 let attrs = entry.get_attributes().expect("get_attributes");
1653 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1654 assert!(attrs.contains_key("uuid"));
1655
1656 let mut spec = HashMap::new();
1657 spec.insert("service", "demo");
1658 spec.insert("user", "alice");
1659 spec.insert("comment", "note");
1660 let results = store.search(&spec).expect("search");
1661 assert_eq!(results.len(), 1);
1662
1663 let uuid = attrs.get("uuid").cloned().expect("get uuid");
1664 let mut spec = HashMap::new();
1665 spec.insert("service", "demo");
1666 spec.insert("user", "alice");
1667 spec.insert("uuid", uuid.as_str());
1668 let results = store.search(&spec).expect("search");
1669 assert_eq!(results.len(), 1);
1670 }
1671
1672 #[test]
1673 fn comment_with_password_round_trip() {
1674 let dir = tempfile::tempdir().expect("tempdir");
1675 let path = dir.path().join("keystore.db");
1676 let store = new_store(&path);
1677 let entry = build_entry(&store, "demo", "alice");
1678 set_password(&entry, "dromomeryx").expect("set_password");
1679
1680 let update = HashMap::from([("comment", "note")]);
1682 entry.update_attributes(&update).expect("update_attributes");
1683
1684 let mut spec = HashMap::new();
1686 spec.insert("service", "demo");
1687 spec.insert("user", "alice");
1688 spec.insert("comment", "note");
1689 let results = store.search(&spec).expect("search");
1690 assert_eq!(results.len(), 1);
1691
1692 let found = &results[0];
1693 let password = get_password(found).expect("password with comment");
1694 assert_eq!(password.as_str(), "dromomeryx");
1695 let attrs = found.get_attributes().expect("get_attributes");
1696 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1697 assert!(attrs.contains_key("uuid"));
1698 }
1699
1700 #[test]
1701 fn build_with_comment_modifier_sets_comment() -> Result<()> {
1702 let dir = tempfile::tempdir().expect("tempdir");
1703 let path = dir.path().join("keystore.db");
1704 let store = new_store(&path);
1705 let entry = store.build(
1706 "demo",
1707 "alice",
1708 Some(&HashMap::from([("comment", "initial")])),
1709 )?;
1710 set_password(&entry, "dromomeryx")?;
1711
1712 let attrs = entry.get_attributes()?;
1713 assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1714 Ok(())
1715 }
1716
1717 #[test]
1718 fn in_memory_store_round_trip() -> Result<()> {
1719 let config = DbKeyStoreConfig {
1720 vfs: Some("memory".to_string()),
1721 ..Default::default()
1722 };
1723 let store = DbKeyStore::new(config)?;
1724 let entry = build_entry(&store, "demo", "alice");
1725 set_password(&entry, "dromomeryx")?;
1726
1727 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1728 assert_eq!(results.len(), 1);
1729 let password = get_password(&results[0])?;
1730 assert_eq!(password.as_str(), "dromomeryx");
1731 Ok(())
1732 }
1733
1734 #[test]
1736 fn stores_separate_service_user_pairs() -> Result<()> {
1737 let dir = tempfile::tempdir().expect("tempdir");
1738 let path = dir.path().join("keystore.db");
1739 let store = new_store(&path);
1740
1741 let entry = build_entry(&store, "myapp", "user1");
1742 set_password(&entry, "pw1")?;
1743 let entry = build_entry(&store, "myapp", "user2");
1744 set_password(&entry, "pw2")?;
1745 let entry = build_entry(&store, "myapp", "user3");
1746 set_password(&entry, "pw3")?;
1747
1748 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1749 assert_eq!(results.len(), 1);
1750 let password = get_password(&results[0])?;
1751 assert_eq!(password.as_str(), "pw1");
1752
1753 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1754 assert_eq!(results.len(), 1);
1755 let password = get_password(&results[0])?;
1756 assert_eq!(password.as_str(), "pw2");
1757
1758 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1759 assert_eq!(results.len(), 1);
1760 let password = get_password(&results[0])?;
1761 assert_eq!(password.as_str(), "pw3");
1762 Ok(())
1763 }
1764
1765 #[test]
1767 fn search_regex() -> Result<()> {
1768 let dir = tempfile::tempdir().expect("tempdir");
1769 let path = dir.path().join("keystore.db");
1770 let store = new_store(&path);
1771
1772 let entry = build_entry(&store, "myapp", "user1");
1773 set_password(&entry, "pw1")?;
1774 let entry = build_entry(&store, "myapp", "user2");
1775 set_password(&entry, "pw2")?;
1776 let entry = build_entry(&store, "myapp", "user3");
1777 set_password(&entry, "pw3")?;
1778 let entry = build_entry(&store, "other-app", "user1");
1779 set_password(&entry, "pw4")?;
1780
1781 let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1783 assert_eq!(results.len(), 2, "search *app, user1");
1784
1785 let results = store.search(&HashMap::from([
1787 ("service", "myapp"),
1788 ("user", "user1|user2"),
1789 ]))?;
1790 assert_eq!(results.len(), 2, "search regex OR");
1791
1792 Ok(())
1793 }
1794
1795 #[test]
1797 fn search_partial() -> Result<()> {
1798 let dir = tempfile::tempdir().expect("tempdir");
1799 let path = dir.path().join("keystore.db");
1800 let store = new_store(&path);
1801
1802 let results = store.search(&HashMap::new())?;
1804 assert_eq!(results.len(), 0, "empty db, no results");
1805
1806 let entry = build_entry(&store, "myapp", "user1");
1807 set_password(&entry, "pw1")?;
1808 let entry = build_entry(&store, "other-app", "user1");
1809 set_password(&entry, "pw2")?;
1810
1811 let results = store.search(&HashMap::new())?;
1813 assert_eq!(results.len(), 2, "search, empty hashmap");
1814
1815 let results = store.search(&HashMap::from([("service", "myapp")]))?;
1817 assert_eq!(results.len(), 1, "search myapp");
1818
1819 let results = store.search(&HashMap::from([("user", "user1")]))?;
1821 assert_eq!(results.len(), 2, "search user1");
1822 Ok(())
1823 }
1824
1825 #[test]
1827 fn repeated_set_replaces_secret() {
1828 let dir = tempfile::tempdir().expect("tempdir");
1829 let path = dir.path().join("keystore.db");
1830 let store = new_store(&path);
1831 let entry = build_entry(&store, "demo", "alice");
1832 set_password(&entry, "first").expect("password set 1");
1833 set_secret(&entry, b"second").expect("password set 2");
1834
1835 let mut spec = HashMap::new();
1836 spec.insert("service", "demo");
1837 spec.insert("user", "alice");
1838 let results = store.search(&spec).expect("search");
1839 assert_eq!(results.len(), 1);
1840 let password = get_password(&results[0]).expect("get first password");
1841 assert_eq!(
1842 password.as_str(),
1843 "second",
1844 "second password overwrites first"
1845 );
1846 }
1847
1848 #[test]
1849 fn same_service_user_entries_share_credential() -> Result<()> {
1850 let dir = tempfile::tempdir().expect("tempdir");
1851 let path = dir.path().join("keystore.db");
1852 let store = new_store(&path);
1853 let entry1 = build_entry(&store, "demo", "alice");
1854 let entry2 = build_entry(&store, "demo", "alice");
1855
1856 set_password(&entry1, "first")?;
1857 let password = get_password(&entry2)?;
1858 assert_eq!(password.as_str(), "first");
1859
1860 set_password(&entry2, "second")?;
1861 let password = get_password(&entry1)?;
1862 assert_eq!(password.as_str(), "second");
1863 Ok(())
1864 }
1865
1866 #[test]
1868 fn remove_returns_no_entry() {
1869 let dir = tempfile::tempdir().expect("tempdir");
1870 let path = dir.path().join("keystore.db");
1871 let store = new_store(&path);
1872 let entry = build_entry(&store, "demo", "alice");
1873 set_password(&entry, "dromomeryx").expect("set password");
1874 entry.delete_credential().expect("delete credential");
1875 let err = entry.delete_credential().unwrap_err();
1876 assert!(matches!(err, Error::NoEntry));
1877 }
1878
1879 #[test]
1881 fn remove_clears_secret() {
1882 let dir = tempfile::tempdir().expect("tempdir");
1883 let path = dir.path().join("keystore.db");
1884 let store = new_store(&path);
1885 let entry = build_entry(&store, "service", "user");
1886 set_password(&entry, "dromomeryx").expect("set password");
1887 entry.delete_credential().expect("delete credential");
1888
1889 let mut spec = HashMap::new();
1890 spec.insert("service", "demo");
1891 spec.insert("user", "alice");
1892 let results = store.search(&spec).expect("search");
1893 assert!(results.is_empty());
1894 }
1895
1896 #[test]
1897 fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1898 let dir = tempfile::tempdir().expect("tempdir");
1899 let path = dir.path().join("keystore.db");
1900 let config = DbKeyStoreConfig {
1901 path: path.clone(),
1902 allow_ambiguity: true,
1903 ..Default::default()
1904 };
1905 let store = DbKeyStore::new(config)?;
1906 let uuid1 = new_uuid();
1907 let uuid2 = new_uuid();
1908 let entry1 = store.build(
1909 "demo",
1910 "alice",
1911 Some(&HashMap::from([
1912 ("uuid", uuid1.as_str()),
1913 ("comment", "one"),
1914 ])),
1915 )?;
1916 let entry2 = store.build(
1917 "demo",
1918 "alice",
1919 Some(&HashMap::from([
1920 ("uuid", uuid2.as_str()),
1921 ("comment", "two"),
1922 ])),
1923 )?;
1924 set_password(&entry1, "first")?;
1925 set_password(&entry2, "second")?;
1926
1927 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1928 assert_eq!(results.len(), 2);
1929
1930 let entry3 = build_entry(&store, "demo", "alice");
1931 let err = entry3.get_password().unwrap_err();
1932 assert!(matches!(err, Error::Ambiguous(_)));
1933 Ok(())
1934 }
1935
1936 #[test]
1937 fn duplicate_uuid_across_service_user_is_scoped() -> Result<()> {
1938 let dir = tempfile::tempdir().expect("tempdir");
1939 let path = dir.path().join("keystore.db");
1940 let config = DbKeyStoreConfig {
1941 path: path.clone(),
1942 allow_ambiguity: true,
1943 ..Default::default()
1944 };
1945 let store = DbKeyStore::new(config)?;
1946 let uuid = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
1947 let entry1 = store.build(
1948 "service-a",
1949 "user-a",
1950 Some(&HashMap::from([("uuid", uuid)])),
1951 )?;
1952 let entry2 = store.build(
1953 "service-b",
1954 "user-b",
1955 Some(&HashMap::from([("uuid", uuid)])),
1956 )?;
1957 set_password(&entry1, "pw1")?;
1958 set_password(&entry2, "pw2")?;
1959
1960 entry1.update_attributes(&HashMap::from([("comment", "note1")]))?;
1961 let attrs1 = entry1.get_attributes()?;
1962 assert_eq!(attrs1.get("comment"), Some(&"note1".to_string()));
1963
1964 let attrs2 = entry2.get_attributes()?;
1965 assert!(!attrs2.contains_key("comment"));
1966
1967 entry1.delete_credential()?;
1968 let pw2 = get_password(&entry2)?;
1969 assert_eq!(pw2.as_str(), "pw2");
1970 Ok(())
1971 }
1972
1973 #[test]
1974 fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1975 let dir = tempfile::tempdir().expect("tempdir");
1976 let path = dir.path().join("keystore.db");
1977 let store = new_store(&path);
1978 let uuid1 = new_uuid();
1979 let uuid2 = new_uuid();
1980 let entry1 = store.build(
1981 "demo",
1982 "alice",
1983 Some(&HashMap::from([("uuid", uuid1.as_str())])),
1984 )?;
1985 let entry2 = store.build(
1986 "demo",
1987 "alice",
1988 Some(&HashMap::from([("uuid", uuid2.as_str())])),
1989 )?;
1990
1991 set_password(&entry1, "first")?;
1992 let err = set_password(&entry2, "second").unwrap_err();
1993 assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1994 Ok(())
1995 }
1996
1997 #[test]
1998 fn impl_debug() -> Result<()> {
1999 let dir = tempfile::tempdir().expect("tempdir");
2000
2001 let path = dir.path().join("keystore1.db");
2002 let store = new_store(&path);
2003 eprintln!("basic: {store:?}");
2004
2005 let path = dir.path().join("keystore2.db");
2006 let config = DbKeyStoreConfig {
2007 path: path.clone(),
2008 encryption_opts: Some(EncryptionOpts::new(
2009 "aes256gcm",
2010 "0000000011111111222222223333333344444444555555556666666677777777",
2011 )?),
2012 ..Default::default()
2013 };
2014 let store = DbKeyStore::new(config)?;
2015 eprintln!("with_enc: {store:?}");
2016
2017 let config = DbKeyStoreConfig {
2018 vfs: Some("memory".to_string()),
2019 ..Default::default()
2020 };
2021 let store = DbKeyStore::new(config)?;
2022 eprintln!("memory: {store:?}");
2023 Ok(())
2024 }
2025
2026 #[test]
2027 fn uuid_v7_strings_are_lexicographically_increasing() {
2028 let mut uuids = Vec::new();
2029 for _ in 0..8 {
2030 uuids.push(new_uuid());
2031 }
2032 for pair in uuids.windows(2) {
2033 assert!(
2034 pair[0] < pair[1],
2035 "uuid v7 strings should be lexicographically increasing"
2036 );
2037 }
2038 }
2039}