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
357impl std::fmt::Debug for DbKeyStore {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("DbKeyStore")
360 .field("vendor", &self.vendor())
361 .field("id", &self.id())
362 .field("allow_ambiguity", &self.inner.allow_ambiguity)
363 .finish()
364 }
365}
366
367impl DbKeyStoreInner {
368 fn connect(&self) -> Result<Connection> {
369 let conn = map_turso(self.db.connect())?;
370 configure_connection(&conn)?;
371 Ok(conn)
372 }
373}
374
375impl DbKeyCredential {
376 async fn insert_credential(
377 &self,
378 conn: &Connection,
379 uuid: &str,
380 secret: Value,
381 comment: Value,
382 ) -> Result<()> {
383 conn.execute(
384 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
385 (
386 self.id.service.as_str(),
387 self.id.user.as_str(),
388 uuid,
389 secret,
390 comment,
391 ),
392 )
393 .await
394 .map_err(map_turso_err)?;
395 Ok(())
396 }
397}
398
399impl CredentialStoreApi for DbKeyStore {
400 fn vendor(&self) -> String {
401 String::from("DbKeyStore, https://crates.io/crates/db-keystore")
402 }
403
404 fn id(&self) -> String {
405 self.inner.id.clone()
406 }
407
408 fn build(
412 &self,
413 service: &str,
414 user: &str,
415 modifiers: Option<&HashMap<&str, &str>>,
416 ) -> Result<Entry> {
417 validate_service_user(service, user)?;
418 let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
419 let credential = DbKeyCredential {
420 inner: Arc::clone(&self.inner),
421 id: CredId {
422 service: service.to_string(),
423 user: user.to_string(),
424 },
425 uuid: mods
426 .get("uuid")
427 .map(|value| normalize_uuid_input(value))
428 .transpose()?,
429 comment: mods.get("comment").cloned(),
430 };
431 Ok(Entry::new_with_credential(Arc::new(credential)))
432 }
433
434 fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
441 let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
442 let service_re = Regex::new(spec.get("service").map_or("", String::as_str))
443 .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
444 let user_re = Regex::new(spec.get("user").map_or("", String::as_str))
445 .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
446 let comment_re = Regex::new(spec.get("comment").map_or("", String::as_str))
447 .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
448 let uuid_spec = match spec.get("uuid") {
449 Some(value) => Some(normalize_uuid_input(value)?),
450 None => None,
451 };
452 let uuid_re = Regex::new(uuid_spec.as_deref().unwrap_or(""))
453 .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
454 let conn = self.inner.connect()?;
455 let rows = map_turso(block_on(query_all_credentials(&conn)))?;
456 let mut entries = Vec::new();
457 let comment_filter = spec.get("comment").cloned();
458 let filter_comment = spec.contains_key("comment");
459 let filter_comment_empty = comment_filter.as_deref().is_some_and(str::is_empty);
460 for (id, uuid, comment) in rows {
461 if !service_re.is_match(id.service.as_str()) {
462 continue;
463 }
464 if !user_re.is_match(id.user.as_str()) {
465 continue;
466 }
467 if !uuid_re.is_match(uuid.as_str()) {
468 continue;
469 }
470 if filter_comment {
471 if filter_comment_empty {
472 if comment.as_deref().is_some_and(|value| !value.is_empty()) {
474 continue;
475 }
476 } else {
477 match comment.as_ref() {
479 Some(text) if comment_re.is_match(text.as_str()) => {}
480 _ => continue,
481 }
482 }
483 }
484 let credential = DbKeyCredential {
485 inner: Arc::clone(&self.inner),
486 id,
487 uuid: Some(uuid),
488 comment: None,
489 };
490 entries.push(Entry::new_with_credential(Arc::new(credential)));
491 }
492 Ok(entries)
493 }
494
495 fn as_any(&self) -> &dyn std::any::Any {
496 self
497 }
498
499 fn persistence(&self) -> CredentialPersistence {
500 CredentialPersistence::UntilDelete
501 }
502
503 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504 fmt::Debug::fmt(self, f)
505 }
506}
507
508impl DbKeyCredential {
509 fn get_secret_zeroizing(&self) -> Result<Zeroizing<Vec<u8>>> {
510 validate_service_user(&self.id.service, &self.id.user)?;
511 let conn = self.inner.connect()?;
512 if let Some(uuid) = &self.uuid {
513 let match_result = map_turso(block_on(fetch_secret_by_key(&conn, &self.id, uuid)))?;
514 match match_result {
515 LookupResult::None => Err(Error::NoEntry),
516 LookupResult::One(secret) => Ok(secret),
517 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
518 &Arc::clone(&self.inner),
519 &self.id,
520 uuids,
521 ))),
522 }
523 } else {
524 let match_result = map_turso(block_on(fetch_secret_by_id(&conn, &self.id)))?;
525 match match_result {
526 LookupResult::None => Err(Error::NoEntry),
527 LookupResult::One(secret) => Ok(secret),
528 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
529 &Arc::clone(&self.inner),
530 &self.id,
531 uuids,
532 ))),
533 }
534 }
535 }
536
537 async fn set_secret_unambiguous(
538 &self,
539 conn: &Connection,
540 make_secret_value: &dyn Fn() -> Value,
541 make_comment_value: &dyn Fn() -> Value,
542 ) -> Result<()> {
543 let uuid = new_uuid();
544 let _ = conn.execute(
545 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
546 ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
547 (
548 self.id.service.as_str(),
549 self.id.user.as_str(),
550 uuid.as_str(),
551 make_secret_value(),
552 make_comment_value(),
553 ),
554 )
555 .await.map_err(map_turso_err)?;
556 Ok(())
557 }
558
559 async fn set_secret_with_uuid(
560 &self,
561 conn: &Connection,
562 uuid: &str,
563 make_secret_value: &dyn Fn() -> Value,
564 make_comment_value: &dyn Fn() -> Value,
565 ) -> Result<()> {
566 let updated = conn
567 .execute(
568 "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
569 (
570 make_secret_value(),
571 self.id.service.as_str(),
572 self.id.user.as_str(),
573 uuid,
574 ),
575 )
576 .await
577 .map_err(map_turso_err)?;
578 if updated > 0 {
579 return Ok(());
580 }
581 if !self.inner.allow_ambiguity {
582 let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
583 match uuids.len() {
584 0 => {}
585 1 => {
586 if uuids[0] != uuid {
587 return Err(Error::Invalid(
588 "uuid".to_string(),
589 "can't create ambiguous credential for service/user".to_string(),
590 ));
591 }
592 }
593 _ => {
594 return Err(Error::PlatformFailure(format!(
596 "Database is in an invalid state: ambiguity not allowed, but multiple entries found for {:?}",
597 &self.id
598 ).into()));
599 }
600 }
601 }
602 self.insert_credential(conn, uuid, make_secret_value(), make_comment_value())
603 .await?;
604 Ok(())
605 }
606
607 async fn set_secret_without_uuid(
608 &self,
609 conn: &Connection,
610 make_secret_value: &dyn Fn() -> Value,
611 make_comment_value: &dyn Fn() -> Value,
612 ) -> Result<()> {
613 let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
614 match uuids.len() {
615 0 => {
616 let uuid = new_uuid();
617 self.insert_credential(
618 conn,
619 uuid.as_str(),
620 make_secret_value(),
621 make_comment_value(),
622 )
623 .await?;
624 Ok(())
625 }
626 1 => {
627 conn.execute(
628 "UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
629 (
630 make_secret_value(),
631 self.id.service.as_str(),
632 self.id.user.as_str(),
633 uuids[0].as_str(),
634 ),
635 )
636 .await
637 .map_err(map_turso_err)?;
638 Ok(())
639 }
640 _ => Err(Error::Ambiguous(ambiguous_entries(
641 &self.inner,
642 &self.id,
643 uuids,
644 ))),
645 }
646 }
647
648 async fn set_secret_in_tx(
649 &self,
650 conn: &Connection,
651 make_secret_value: &dyn Fn() -> Value,
652 make_comment_value: &dyn Fn() -> Value,
653 ) -> Result<()> {
654 if let Some(uuid) = &self.uuid {
655 self.set_secret_with_uuid(conn, uuid.as_str(), make_secret_value, make_comment_value)
656 .await
657 } else {
658 self.set_secret_without_uuid(conn, make_secret_value, make_comment_value)
659 .await
660 }
661 }
662
663 async fn finish_tx(conn: &Connection, result: Result<()>) -> Result<()> {
664 match result {
665 Ok(()) => {
666 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
667 Ok(())
668 }
669 Err(err) => {
670 if let Err(e2) = conn.execute("ROLLBACK", ()).await {
671 log::error!(
672 "While handling set_secret error ({err:?}). attempted ROLLBACK, which encountered secondary error: {e2:?}"
673 );
674 }
675 Err(err)
676 }
677 }
678 }
679}
680
681impl CredentialApi for DbKeyCredential {
682 fn set_secret(&self, secret: &[u8]) -> Result<()> {
683 validate_service_user(&self.id.service, &self.id.user)?;
684 validate_secret(secret)?;
685 let make_secret_value = || Value::Blob(secret.to_vec());
686 let make_comment_value = || comment_value(self.comment.as_ref());
687 let conn = self.inner.connect()?;
688 if self.uuid.is_none() && !self.inner.allow_ambiguity {
689 return block_on(self.set_secret_unambiguous(
690 &conn,
691 &make_secret_value,
692 &make_comment_value,
693 ));
694 }
695 block_on(async {
696 conn.execute("BEGIN IMMEDIATE", ())
697 .await
698 .map_err(map_turso_err)?;
699 let result = self
700 .set_secret_in_tx(&conn, &make_secret_value, &make_comment_value)
701 .await;
702 Self::finish_tx(&conn, result).await
703 })
704 }
705
706 fn get_secret(&self) -> Result<Vec<u8>> {
707 let secret = self.get_secret_zeroizing()?;
708 Ok(take_zeroizing_vec(secret))
709 }
710
711 fn get_attributes(&self) -> Result<HashMap<String, String>> {
712 validate_service_user(&self.id.service, &self.id.user)?;
713 let conn = self.inner.connect()?;
714 if let Some(uuid) = &self.uuid {
715 let match_result = map_turso(block_on(fetch_comment_by_key(&conn, &self.id, uuid)))?;
716 match match_result {
717 LookupResult::None => Err(Error::NoEntry),
718 LookupResult::One(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
719 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
720 &self.inner,
721 &self.id,
722 uuids,
723 ))),
724 }
725 } else {
726 let match_result = map_turso(block_on(fetch_comment_by_id(&conn, &self.id)))?;
727 match match_result {
728 LookupResult::None => Err(Error::NoEntry),
729 LookupResult::One(row) => Ok(attributes_for_uuid(row.uuid.as_str(), row.comment)),
730 LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
731 &self.inner,
732 &self.id,
733 uuids,
734 ))),
735 }
736 }
737 }
738
739 fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
740 parse_attributes(&["comment"], Some(attrs))?;
741 let comment = attrs.get("comment").map(ToString::to_string);
742 let has_comment = attrs.contains_key("comment");
743 if !has_comment {
744 self.get_attributes()?;
745 return Ok(());
746 }
747 let comment = comment.and_then(|value| if value.is_empty() { None } else { Some(value) });
748 let make_comment_value = || comment_value(comment.as_ref());
749 let conn = self.inner.connect()?;
750 block_on(async {
751 conn.execute("BEGIN IMMEDIATE", ())
752 .await
753 .map_err(map_turso_err)?;
754 let result = match &self.uuid {
755 Some(uuid) => {
756 let uuids = fetch_uuids_by_key(&conn, &self.id, uuid)
757 .await
758 .map_err(map_turso_err)?;
759 match uuids.len() {
760 0 => Err(Error::NoEntry),
761 1 => {
762 conn.execute(
763 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
764 (
765 make_comment_value(),
766 self.id.service.as_str(),
767 self.id.user.as_str(),
768 uuid.as_str(),
769 ),
770 )
771 .await
772 .map_err(map_turso_err)?;
773 Ok(())
774 }
775 _ => Err(Error::Ambiguous(ambiguous_entries(
776 &self.inner,
777 &self.id,
778 uuids,
779 ))),
780 }
781 }
782 None if self.inner.allow_ambiguity => {
783 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
784 match uuids.len() {
785 0 => Err(Error::NoEntry),
786 1 => {
787 conn.execute(
788 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
789 (
790 make_comment_value(),
791 self.id.service.as_str(),
792 self.id.user.as_str(),
793 uuids[0].as_str(),
794 ),
795 )
796 .await
797 .map_err(map_turso_err)?;
798 Ok(())
799 }
800 _ => Err(Error::Ambiguous(ambiguous_entries(
801 &self.inner,
802 &self.id,
803 uuids,
804 ))),
805 }
806 }
807 None => {
808 let updated = conn
809 .execute(
810 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
811 (
812 make_comment_value(),
813 self.id.service.as_str(),
814 self.id.user.as_str(),
815 ),
816 )
817 .await
818 .map_err(map_turso_err)?;
819 if updated == 0 {
820 Err(Error::NoEntry)
821 } else {
822 Ok(())
823 }
824 }
825 };
826 match result {
827 Ok(()) => {
828 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
829 Ok(())
830 }
831 Err(err) => {
832 let _ = conn.execute("ROLLBACK", ()).await;
834 Err(err)
835 }
836 }
837 })
838 }
839
840 fn delete_credential(&self) -> Result<()> {
841 validate_service_user(&self.id.service, &self.id.user)?;
842 let conn = self.inner.connect()?;
843 if let Some(uuid) = &self.uuid {
844 let deleted = map_turso(block_on(conn.execute(
845 "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
846 (
847 self.id.service.as_str(),
848 self.id.user.as_str(),
849 uuid.as_str(),
850 ),
851 )))?;
852 if deleted == 0 {
853 Err(Error::NoEntry)
854 } else {
855 Ok(())
856 }
857 } else {
858 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
859 match uuids.len() {
860 0 => Err(Error::NoEntry),
861 1 => {
862 map_turso(block_on(conn.execute(
863 "DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
864 (
865 self.id.service.as_str(),
866 self.id.user.as_str(),
867 uuids[0].as_str(),
868 ),
869 )))?;
870 Ok(())
871 }
872 _ => Err(Error::Ambiguous(ambiguous_entries(
873 &self.inner,
874 &self.id,
875 uuids,
876 ))),
877 }
878 }
879 }
880
881 fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
882 validate_service_user(&self.id.service, &self.id.user)?;
883 let conn = self.inner.connect()?;
884 if let Some(uuid) = &self.uuid {
885 let uuids = map_turso(block_on(fetch_uuids_by_key(&conn, &self.id, uuid)))?;
886 match uuids.len() {
887 0 => Err(Error::NoEntry),
888 1 => Ok(Some(Arc::new(DbKeyCredential {
889 inner: Arc::clone(&self.inner),
890 id: self.id.clone(),
891 uuid: Some(uuid.clone()),
892 comment: None,
893 }))),
894 _ => Err(Error::Ambiguous(ambiguous_entries(
895 &self.inner,
896 &self.id,
897 uuids,
898 ))),
899 }
900 } else {
901 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
902 match uuids.len() {
903 0 => Err(Error::NoEntry),
904 1 => Ok(Some(Arc::new(DbKeyCredential {
905 inner: Arc::clone(&self.inner),
906 id: self.id.clone(),
907 uuid: Some(uuids[0].clone()),
908 comment: None,
909 }))),
910 _ => Err(Error::Ambiguous(ambiguous_entries(
911 &self.inner,
912 &self.id,
913 uuids,
914 ))),
915 }
916 }
917 }
918
919 fn get_specifiers(&self) -> Option<(String, String)> {
920 Some((self.id.service.clone(), self.id.user.clone()))
921 }
922
923 fn as_any(&self) -> &dyn std::any::Any {
924 self
925 }
926
927 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
928 fmt::Debug::fmt(self, f)
929 }
930}
931
932fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
933 map_turso(block_on(conn.execute(
934 "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
935 (),
936 )))?;
937 map_turso(block_on(conn.execute(
938 "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
939 (),
940 )))?;
941 ensure_schema_version(conn)?;
942 if !allow_ambiguity {
943 map_turso(block_on(conn.execute(
945 "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
946 (),
947 )))?;
948 } else if index_always {
949 map_turso(block_on(conn.execute(
954 "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
955 (),
956 )))?;
957 }
958 Ok(())
959}
960
961fn ensure_schema_version(conn: &Connection) -> Result<()> {
962 map_turso(block_on(async {
963 let mut rows = conn
964 .query(
965 "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
966 (),
967 )
968 .await?;
969 if let Some(row) = rows.next().await? {
970 let value = value_to_string(row.get_value(0)?, "schema_version")?;
971 let version = value.parse::<u32>().map_err(|_| {
972 turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
973 })?;
974 if version != SCHEMA_VERSION {
975 return Err(turso::Error::ConversionFailure(format!(
976 "unsupported schema version: {version}"
977 )));
978 }
979 } else {
980 conn.execute(
981 "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
982 (SCHEMA_VERSION.to_string(),),
983 )
984 .await?;
985 }
986 Ok(())
987 }))
988}
989
990async fn query_all_credentials(
991 conn: &Connection,
992) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
993 let mut rows = conn
994 .query("SELECT service, user, uuid, comment FROM credentials", ())
995 .await?;
996 let mut results = Vec::new();
997 while let Some(row) = rows.next().await? {
998 let service = value_to_string(row.get_value(0)?, "service")?;
999 let user = value_to_string(row.get_value(1)?, "user")?;
1000 let uuid = value_to_string(row.get_value(2)?, "uuid")?;
1001 let comment = value_to_option_string(row.get_value(3)?, "comment")?;
1002 results.push((CredId { service, user }, uuid, comment));
1003 }
1004 Ok(results)
1005}
1006
1007async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
1008 let mut rows = conn
1009 .query(
1010 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
1011 (id.service.as_str(), id.user.as_str()),
1012 )
1013 .await?;
1014 let mut uuids = Vec::new();
1015 while let Some(row) = rows.next().await? {
1016 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1017 uuids.push(uuid);
1018 }
1019 Ok(uuids)
1020}
1021
1022async fn fetch_secret_by_key(
1023 conn: &Connection,
1024 id: &CredId,
1025 uuid: &str,
1026) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1027 let mut rows = conn
1028 .query(
1029 "SELECT secret FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1030 (id.service.as_str(), id.user.as_str(), uuid),
1031 )
1032 .await?;
1033 let mut secrets = Vec::new();
1034 while let Some(row) = rows.next().await? {
1035 let secret = value_to_secret(row.get_value(0)?, "secret")?;
1036 secrets.push(secret);
1037 }
1038 match secrets.len() {
1039 0 => Ok(LookupResult::None),
1040 1 => Ok(LookupResult::One(
1041 secrets.into_iter().next().expect("secret for single match"),
1042 )),
1043 _ => Ok(LookupResult::Ambiguous(vec![
1044 uuid.to_string();
1045 secrets.len()
1046 ])),
1047 }
1048}
1049
1050async fn fetch_comment_by_key(
1051 conn: &Connection,
1052 id: &CredId,
1053 uuid: &str,
1054) -> turso::Result<LookupResult<Option<String>>> {
1055 let mut rows = conn
1056 .query(
1057 "SELECT comment FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1058 (id.service.as_str(), id.user.as_str(), uuid),
1059 )
1060 .await?;
1061 let mut comments = Vec::new();
1062 while let Some(row) = rows.next().await? {
1063 let comment = value_to_option_string(row.get_value(0)?, "comment")?;
1064 comments.push(comment);
1065 }
1066 match comments.len() {
1067 0 => Ok(LookupResult::None),
1068 1 => Ok(LookupResult::One(
1069 comments
1070 .into_iter()
1071 .next()
1072 .expect("comment for single match"),
1073 )),
1074 _ => Ok(LookupResult::Ambiguous(vec![
1075 uuid.to_string();
1076 comments.len()
1077 ])),
1078 }
1079}
1080
1081async fn fetch_secret_by_id(
1082 conn: &Connection,
1083 id: &CredId,
1084) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
1085 let uuids = fetch_uuids(conn, id).await?;
1086 match uuids.len() {
1087 0 => Ok(LookupResult::None),
1088 1 => fetch_secret_by_key(conn, id, uuids[0].as_str()).await,
1089 _ => Ok(LookupResult::Ambiguous(uuids)),
1090 }
1091}
1092
1093async fn fetch_comment_by_id(
1094 conn: &Connection,
1095 id: &CredId,
1096) -> turso::Result<LookupResult<CommentRow>> {
1097 let uuids = fetch_uuids(conn, id).await?;
1098 match uuids.len() {
1099 0 => Ok(LookupResult::None),
1100 1 => {
1101 let uuid = uuids.into_iter().next().expect("uuid");
1102 match fetch_comment_by_key(conn, id, uuid.as_str()).await? {
1103 LookupResult::None => Ok(LookupResult::None),
1104 LookupResult::One(comment) => Ok(LookupResult::One(CommentRow { uuid, comment })),
1105 LookupResult::Ambiguous(uuids) => Ok(LookupResult::Ambiguous(uuids)),
1106 }
1107 }
1108 _ => Ok(LookupResult::Ambiguous(uuids)),
1109 }
1110}
1111
1112async fn fetch_uuids_by_key(
1113 conn: &Connection,
1114 id: &CredId,
1115 uuid: &str,
1116) -> turso::Result<Vec<String>> {
1117 let mut rows = conn
1118 .query(
1119 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
1120 (id.service.as_str(), id.user.as_str(), uuid),
1121 )
1122 .await?;
1123 let mut uuids = Vec::new();
1124 while let Some(row) = rows.next().await? {
1125 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
1126 uuids.push(uuid);
1127 }
1128 Ok(uuids)
1129}
1130
1131fn ambiguous_entries(inner: &Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
1132 uuids
1133 .into_iter()
1134 .map(|uuid| {
1135 Entry::new_with_credential(Arc::new(DbKeyCredential {
1136 inner: Arc::clone(inner),
1137 id: id.clone(),
1138 uuid: Some(uuid),
1139 comment: None,
1140 }))
1141 })
1142 .collect()
1143}
1144
1145fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
1146 let mut attrs = HashMap::new();
1147 attrs.insert("uuid".to_string(), uuid.to_string());
1148 if let Some(comment) = comment {
1149 attrs.insert("comment".to_string(), comment);
1150 }
1151 attrs
1152}
1153
1154fn comment_value(comment: Option<&String>) -> Value {
1155 match comment {
1156 Some(value) if !value.is_empty() => Value::Text(value.clone()),
1157 _ => Value::Null,
1158 }
1159}
1160
1161fn normalize_uuid_input(value: &str) -> Result<String> {
1162 let lower = value.to_ascii_lowercase();
1163 let uuid = uuid::Uuid::try_parse(&lower)
1164 .map_err(|_| Error::Invalid("uuid".to_string(), "invalid uuid format".to_string()))?;
1165 if uuid.to_string() != lower {
1166 return Err(Error::Invalid(
1167 "uuid".to_string(),
1168 "invalid uuid format".to_string(),
1169 ));
1170 }
1171 Ok(lower)
1172}
1173
1174fn take_zeroizing_vec(mut value: Zeroizing<Vec<u8>>) -> Vec<u8> {
1175 std::mem::take(&mut *value)
1176}
1177
1178fn configure_connection(conn: &Connection) -> Result<()> {
1182 map_turso(block_on(async {
1183 let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
1184 let _ = rows.next().await?;
1185 let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
1186 conn.execute(busy_stmt.as_str(), ()).await?;
1187 Ok(())
1188 }))
1189}
1190
1191fn open_db_with_retry(
1193 path_str: &str,
1194 encryption_opts: Option<&EncryptionOptsZero>,
1195 vfs: Option<&str>,
1196) -> Result<Database> {
1197 let mut retries = OPEN_LOCK_RETRIES;
1198 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1199 loop {
1200 let mut builder = Builder::new_local(path_str);
1201 if let Some(opts) = &encryption_opts {
1202 let turso_enc_opts = turso::EncryptionOpts {
1203 cipher: opts.cipher.clone(),
1204 hexkey: opts.hexkey.to_string(),
1206 };
1207 builder = builder
1208 .experimental_encryption(true)
1209 .with_encryption(turso_enc_opts);
1210 }
1211 if let Some(vfs) = vfs {
1212 builder = builder.with_io(vfs.to_string());
1213 }
1214 match block_on(builder.build()) {
1215 Ok(db) => return Ok(db),
1216 Err(err) => {
1217 if retries == 0 || !is_turso_locking_error(&err) {
1218 return Err(map_turso_err(err));
1219 }
1220 retries -= 1;
1221 let nanos = SystemTime::now()
1222 .duration_since(UNIX_EPOCH)
1223 .unwrap_or_default()
1224 .subsec_nanos();
1225 let jitter = u64::from(nanos % 20);
1226 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1227 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1228 }
1229 }
1230 }
1231}
1232
1233fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
1234 let mut retries = OPEN_LOCK_RETRIES;
1235 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
1236 loop {
1237 match op() {
1238 Ok(value) => return Ok(value),
1239 Err(err) => {
1240 if retries == 0 || !is_turso_locking_error(&err) {
1241 return Err(map_turso_err(err));
1242 }
1243 retries -= 1;
1244 let nanos = SystemTime::now()
1245 .duration_since(UNIX_EPOCH)
1246 .unwrap_or_default()
1247 .subsec_nanos();
1248 let jitter = u64::from(nanos % 20);
1249 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
1250 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
1251 }
1252 }
1253 }
1254}
1255
1256fn is_turso_locking_error(err: &turso::Error) -> bool {
1257 let text = err.to_string().to_lowercase();
1258 text.contains("locking error")
1259 || text.contains("file is locked")
1260 || text.contains("database is locked")
1261 || text.contains("database is busy")
1262 || text.contains("sqlite_busy")
1263 || text.contains("sqlite_locked")
1264}
1265
1266fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
1267 match value {
1268 Value::Text(text) => Ok(text),
1269 Value::Blob(blob) => String::from_utf8(blob)
1270 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1271 other => Err(turso::Error::ConversionFailure(format!(
1272 "unexpected value for {field}: {other:?}"
1273 ))),
1274 }
1275}
1276
1277fn value_to_secret(value: Value, field: &str) -> turso::Result<Zeroizing<Vec<u8>>> {
1278 match value {
1279 Value::Blob(blob) => Ok(Zeroizing::new(blob)),
1280 Value::Text(text) => Ok(Zeroizing::new(text.into_bytes())),
1281 other => Err(turso::Error::ConversionFailure(format!(
1282 "unexpected value for {field}: {other:?}"
1283 ))),
1284 }
1285}
1286
1287fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1288 match value {
1289 Value::Null => Ok(None),
1290 Value::Text(text) => Ok(Some(text)),
1291 Value::Blob(blob) => String::from_utf8(blob)
1292 .map(Some)
1293 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1294 other => Err(turso::Error::ConversionFailure(format!(
1295 "unexpected value for {field}: {other:?}"
1296 ))),
1297 }
1298}
1299
1300fn ensure_parent_dir(path: &Path) -> Result<()> {
1301 let parent = path
1302 .parent()
1303 .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1304 if parent.as_os_str().is_empty() {
1305 return Ok(());
1306 }
1307 std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1308}
1309
1310fn validate_service_user(service: &str, user: &str) -> Result<()> {
1312 if service.is_empty() {
1313 return Err(Error::Invalid(
1314 "service".to_string(),
1315 "service is empty".to_string(),
1316 ));
1317 }
1318 if user.is_empty() {
1319 return Err(Error::Invalid(
1320 "user".to_string(),
1321 "user is empty".to_string(),
1322 ));
1323 }
1324 if service.len() > MAX_NAME_LEN as usize {
1325 return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN));
1326 }
1327 if user.len() > MAX_NAME_LEN as usize {
1328 return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN));
1329 }
1330 Ok(())
1331}
1332
1333fn validate_secret(secret: &[u8]) -> Result<()> {
1335 if secret.len() > MAX_SECRET_LEN as usize {
1336 return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN));
1337 }
1338 Ok(())
1339}
1340
1341fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1342 result.map_err(map_turso_err)
1343}
1344
1345fn map_turso_err(err: turso::Error) -> Error {
1346 Error::PlatformFailure(Box::new(err))
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352
1353 fn new_store(path: &Path) -> Arc<DbKeyStore> {
1354 let config = DbKeyStoreConfig {
1355 path: path.to_path_buf(),
1356 ..Default::default()
1357 };
1358 DbKeyStore::new(config).expect("failed to create store")
1359 }
1360
1361 fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1362 store
1363 .build(service, user, None)
1364 .expect("failed to build entry")
1365 }
1366
1367 fn set_password(entry: &Entry, password: &str) -> Result<()> {
1368 entry.set_password(password)
1369 }
1370
1371 fn set_secret(entry: &Entry, secret: &[u8]) -> Result<()> {
1372 entry.set_secret(secret)
1373 }
1374
1375 fn get_password(entry: &Entry) -> Result<Zeroizing<String>> {
1376 Ok(Zeroizing::new(entry.get_password()?))
1377 }
1378
1379 #[test]
1381 fn create_store_creates_parent_dir() {
1382 let dir = tempfile::tempdir().expect("tempdir");
1383 let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1384 let parent = db_path.parent().expect("parent");
1385 assert!(!parent.exists());
1386
1387 let config = DbKeyStoreConfig {
1388 path: db_path.clone(),
1389 ..Default::default()
1390 };
1391 let store = DbKeyStore::new(config).expect("create store");
1392 assert!(parent.is_dir());
1393
1394 let entry = build_entry(&store, "demo", "alice");
1395 set_password(&entry, "dromomeryx").expect("set_password");
1396 }
1397
1398 #[test]
1400 fn set_password_then_search_finds_password() {
1401 let dir = tempfile::tempdir().expect("tempdir");
1402 let path = dir.path().join("keystore.db");
1403 let store = new_store(&path);
1404 let entry = build_entry(&store, "demo", "alice");
1405 set_password(&entry, "dromomeryx").expect("set_password");
1406
1407 let mut spec = HashMap::new();
1408 spec.insert("service", "demo");
1409 spec.insert("user", "alice");
1410 let results = store.search(&spec).expect("search");
1411 assert_eq!(results.len(), 1);
1412 let password = get_password(&results[0]).expect("get_password");
1413 assert_eq!(password.as_str(), "dromomeryx");
1414 }
1415
1416 #[test]
1418 fn comment_attributes_round_trip() {
1419 let dir = tempfile::tempdir().expect("tempdir");
1420 let path = dir.path().join("keystore.db");
1421 let store = new_store(&path);
1422 let entry = build_entry(&store, "demo", "alice");
1423 set_password(&entry, "dromomeryx").expect("set_password");
1424
1425 let update = HashMap::from([("comment", "note")]);
1426 entry.update_attributes(&update).expect("update_attributes");
1427 let attrs = entry.get_attributes().expect("get_attributes");
1428 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1429 assert!(attrs.contains_key("uuid"));
1430
1431 let mut spec = HashMap::new();
1432 spec.insert("service", "demo");
1433 spec.insert("user", "alice");
1434 spec.insert("comment", "note");
1435 let results = store.search(&spec).expect("search");
1436 assert_eq!(results.len(), 1);
1437
1438 let uuid = attrs.get("uuid").cloned().expect("get uuid");
1439 let mut spec = HashMap::new();
1440 spec.insert("service", "demo");
1441 spec.insert("user", "alice");
1442 spec.insert("uuid", uuid.as_str());
1443 let results = store.search(&spec).expect("search");
1444 assert_eq!(results.len(), 1);
1445 }
1446
1447 #[test]
1448 fn comment_with_password_round_trip() {
1449 let dir = tempfile::tempdir().expect("tempdir");
1450 let path = dir.path().join("keystore.db");
1451 let store = new_store(&path);
1452 let entry = build_entry(&store, "demo", "alice");
1453 set_password(&entry, "dromomeryx").expect("set_password");
1454
1455 let update = HashMap::from([("comment", "note")]);
1457 entry.update_attributes(&update).expect("update_attributes");
1458
1459 let mut spec = HashMap::new();
1461 spec.insert("service", "demo");
1462 spec.insert("user", "alice");
1463 spec.insert("comment", "note");
1464 let results = store.search(&spec).expect("search");
1465 assert_eq!(results.len(), 1);
1466
1467 let found = &results[0];
1468 let password = get_password(found).expect("password with comment");
1469 assert_eq!(password.as_str(), "dromomeryx");
1470 let attrs = found.get_attributes().expect("get_attributes");
1471 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1472 assert!(attrs.contains_key("uuid"));
1473 }
1474
1475 #[test]
1476 fn build_with_comment_modifier_sets_comment() -> Result<()> {
1477 let dir = tempfile::tempdir().expect("tempdir");
1478 let path = dir.path().join("keystore.db");
1479 let store = new_store(&path);
1480 let entry = store.build(
1481 "demo",
1482 "alice",
1483 Some(&HashMap::from([("comment", "initial")])),
1484 )?;
1485 set_password(&entry, "dromomeryx")?;
1486
1487 let attrs = entry.get_attributes()?;
1488 assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1489 Ok(())
1490 }
1491
1492 #[test]
1493 fn in_memory_store_round_trip() -> Result<()> {
1494 let config = DbKeyStoreConfig {
1495 vfs: Some("memory".to_string()),
1496 ..Default::default()
1497 };
1498 let store = DbKeyStore::new(config)?;
1499 let entry = build_entry(&store, "demo", "alice");
1500 set_password(&entry, "dromomeryx")?;
1501
1502 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1503 assert_eq!(results.len(), 1);
1504 let password = get_password(&results[0])?;
1505 assert_eq!(password.as_str(), "dromomeryx");
1506 Ok(())
1507 }
1508
1509 #[test]
1511 fn stores_separate_service_user_pairs() -> Result<()> {
1512 let dir = tempfile::tempdir().expect("tempdir");
1513 let path = dir.path().join("keystore.db");
1514 let store = new_store(&path);
1515
1516 let entry = build_entry(&store, "myapp", "user1");
1517 set_password(&entry, "pw1")?;
1518 let entry = build_entry(&store, "myapp", "user2");
1519 set_password(&entry, "pw2")?;
1520 let entry = build_entry(&store, "myapp", "user3");
1521 set_password(&entry, "pw3")?;
1522
1523 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1524 assert_eq!(results.len(), 1);
1525 let password = get_password(&results[0])?;
1526 assert_eq!(password.as_str(), "pw1");
1527
1528 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1529 assert_eq!(results.len(), 1);
1530 let password = get_password(&results[0])?;
1531 assert_eq!(password.as_str(), "pw2");
1532
1533 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1534 assert_eq!(results.len(), 1);
1535 let password = get_password(&results[0])?;
1536 assert_eq!(password.as_str(), "pw3");
1537 Ok(())
1538 }
1539
1540 #[test]
1542 fn search_regex() -> Result<()> {
1543 let dir = tempfile::tempdir().expect("tempdir");
1544 let path = dir.path().join("keystore.db");
1545 let store = new_store(&path);
1546
1547 let entry = build_entry(&store, "myapp", "user1");
1548 set_password(&entry, "pw1")?;
1549 let entry = build_entry(&store, "myapp", "user2");
1550 set_password(&entry, "pw2")?;
1551 let entry = build_entry(&store, "myapp", "user3");
1552 set_password(&entry, "pw3")?;
1553 let entry = build_entry(&store, "other-app", "user1");
1554 set_password(&entry, "pw4")?;
1555
1556 let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1558 assert_eq!(results.len(), 2, "search *app, user1");
1559
1560 let results = store.search(&HashMap::from([
1562 ("service", "myapp"),
1563 ("user", "user1|user2"),
1564 ]))?;
1565 assert_eq!(results.len(), 2, "search regex OR");
1566
1567 Ok(())
1568 }
1569
1570 #[test]
1572 fn search_partial() -> Result<()> {
1573 let dir = tempfile::tempdir().expect("tempdir");
1574 let path = dir.path().join("keystore.db");
1575 let store = new_store(&path);
1576
1577 let results = store.search(&HashMap::new())?;
1579 assert_eq!(results.len(), 0, "empty db, no results");
1580
1581 let entry = build_entry(&store, "myapp", "user1");
1582 set_password(&entry, "pw1")?;
1583 let entry = build_entry(&store, "other-app", "user1");
1584 set_password(&entry, "pw2")?;
1585
1586 let results = store.search(&HashMap::new())?;
1588 assert_eq!(results.len(), 2, "search, empty hashmap");
1589
1590 let results = store.search(&HashMap::from([("service", "myapp")]))?;
1592 assert_eq!(results.len(), 1, "search myapp");
1593
1594 let results = store.search(&HashMap::from([("user", "user1")]))?;
1596 assert_eq!(results.len(), 2, "search user1");
1597 Ok(())
1598 }
1599
1600 #[test]
1602 fn repeated_set_replaces_secret() {
1603 let dir = tempfile::tempdir().expect("tempdir");
1604 let path = dir.path().join("keystore.db");
1605 let store = new_store(&path);
1606 let entry = build_entry(&store, "demo", "alice");
1607 set_password(&entry, "first").expect("password set 1");
1608 set_secret(&entry, b"second").expect("password set 2");
1609
1610 let mut spec = HashMap::new();
1611 spec.insert("service", "demo");
1612 spec.insert("user", "alice");
1613 let results = store.search(&spec).expect("search");
1614 assert_eq!(results.len(), 1);
1615 let password = get_password(&results[0]).expect("get first password");
1616 assert_eq!(
1617 password.as_str(),
1618 "second",
1619 "second password overwrites first"
1620 );
1621 }
1622
1623 #[test]
1624 fn same_service_user_entries_share_credential() -> Result<()> {
1625 let dir = tempfile::tempdir().expect("tempdir");
1626 let path = dir.path().join("keystore.db");
1627 let store = new_store(&path);
1628 let entry1 = build_entry(&store, "demo", "alice");
1629 let entry2 = build_entry(&store, "demo", "alice");
1630
1631 set_password(&entry1, "first")?;
1632 let password = get_password(&entry2)?;
1633 assert_eq!(password.as_str(), "first");
1634
1635 set_password(&entry2, "second")?;
1636 let password = get_password(&entry1)?;
1637 assert_eq!(password.as_str(), "second");
1638 Ok(())
1639 }
1640
1641 #[test]
1643 fn remove_returns_no_entry() {
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 entry.delete_credential().expect("delete credential");
1650 let err = entry.delete_credential().unwrap_err();
1651 assert!(matches!(err, Error::NoEntry));
1652 }
1653
1654 #[test]
1656 fn remove_clears_secret() {
1657 let dir = tempfile::tempdir().expect("tempdir");
1658 let path = dir.path().join("keystore.db");
1659 let store = new_store(&path);
1660 let entry = build_entry(&store, "service", "user");
1661 set_password(&entry, "dromomeryx").expect("set password");
1662 entry.delete_credential().expect("delete credential");
1663
1664 let mut spec = HashMap::new();
1665 spec.insert("service", "demo");
1666 spec.insert("user", "alice");
1667 let results = store.search(&spec).expect("search");
1668 assert!(results.is_empty());
1669 }
1670
1671 #[test]
1672 fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1673 let dir = tempfile::tempdir().expect("tempdir");
1674 let path = dir.path().join("keystore.db");
1675 let config = DbKeyStoreConfig {
1676 path: path.clone(),
1677 allow_ambiguity: true,
1678 ..Default::default()
1679 };
1680 let store = DbKeyStore::new(config)?;
1681 let uuid1 = new_uuid();
1682 let uuid2 = new_uuid();
1683 let entry1 = store.build(
1684 "demo",
1685 "alice",
1686 Some(&HashMap::from([
1687 ("uuid", uuid1.as_str()),
1688 ("comment", "one"),
1689 ])),
1690 )?;
1691 let entry2 = store.build(
1692 "demo",
1693 "alice",
1694 Some(&HashMap::from([
1695 ("uuid", uuid2.as_str()),
1696 ("comment", "two"),
1697 ])),
1698 )?;
1699 set_password(&entry1, "first")?;
1700 set_password(&entry2, "second")?;
1701
1702 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1703 assert_eq!(results.len(), 2);
1704
1705 let entry3 = build_entry(&store, "demo", "alice");
1706 let err = entry3.get_password().unwrap_err();
1707 assert!(matches!(err, Error::Ambiguous(_)));
1708 Ok(())
1709 }
1710
1711 #[test]
1712 fn duplicate_uuid_across_service_user_is_scoped() -> Result<()> {
1713 let dir = tempfile::tempdir().expect("tempdir");
1714 let path = dir.path().join("keystore.db");
1715 let config = DbKeyStoreConfig {
1716 path: path.clone(),
1717 allow_ambiguity: true,
1718 ..Default::default()
1719 };
1720 let store = DbKeyStore::new(config)?;
1721 let uuid = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
1722 let entry1 = store.build(
1723 "service-a",
1724 "user-a",
1725 Some(&HashMap::from([("uuid", uuid)])),
1726 )?;
1727 let entry2 = store.build(
1728 "service-b",
1729 "user-b",
1730 Some(&HashMap::from([("uuid", uuid)])),
1731 )?;
1732 set_password(&entry1, "pw1")?;
1733 set_password(&entry2, "pw2")?;
1734
1735 entry1.update_attributes(&HashMap::from([("comment", "note1")]))?;
1736 let attrs1 = entry1.get_attributes()?;
1737 assert_eq!(attrs1.get("comment"), Some(&"note1".to_string()));
1738
1739 let attrs2 = entry2.get_attributes()?;
1740 assert!(!attrs2.contains_key("comment"));
1741
1742 entry1.delete_credential()?;
1743 let pw2 = get_password(&entry2)?;
1744 assert_eq!(pw2.as_str(), "pw2");
1745 Ok(())
1746 }
1747
1748 #[test]
1749 fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1750 let dir = tempfile::tempdir().expect("tempdir");
1751 let path = dir.path().join("keystore.db");
1752 let store = new_store(&path);
1753 let uuid1 = new_uuid();
1754 let uuid2 = new_uuid();
1755 let entry1 = store.build(
1756 "demo",
1757 "alice",
1758 Some(&HashMap::from([("uuid", uuid1.as_str())])),
1759 )?;
1760 let entry2 = store.build(
1761 "demo",
1762 "alice",
1763 Some(&HashMap::from([("uuid", uuid2.as_str())])),
1764 )?;
1765
1766 set_password(&entry1, "first")?;
1767 let err = set_password(&entry2, "second").unwrap_err();
1768 assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1769 Ok(())
1770 }
1771
1772 #[test]
1773 fn impl_debug() -> Result<()> {
1774 let dir = tempfile::tempdir().expect("tempdir");
1775
1776 let path = dir.path().join("keystore1.db");
1777 let store = new_store(&path);
1778 eprintln!("basic: {store:?}");
1779
1780 let path = dir.path().join("keystore2.db");
1781 let config = DbKeyStoreConfig {
1782 path: path.clone(),
1783 encryption_opts: Some(EncryptionOpts::new(
1784 "aes256gcm",
1785 "0000000011111111222222223333333344444444555555556666666677777777",
1786 )),
1787 ..Default::default()
1788 };
1789 let store = DbKeyStore::new(config)?;
1790 eprintln!("with_enc: {store:?}");
1791
1792 let config = DbKeyStoreConfig {
1793 vfs: Some("memory".to_string()),
1794 ..Default::default()
1795 };
1796 let store = DbKeyStore::new(config)?;
1797 eprintln!("memory: {store:?}");
1798 Ok(())
1799 }
1800
1801 #[test]
1802 fn uuid_v7_strings_are_lexicographically_increasing() {
1803 let mut uuids = Vec::new();
1804 for _ in 0..8 {
1805 uuids.push(new_uuid());
1806 }
1807 for pair in uuids.windows(2) {
1808 assert!(
1809 pair[0] < pair[1],
1810 "uuid v7 strings should be lexicographically increasing"
1811 );
1812 }
1813 }
1814}