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