1use std::{
59 collections::HashMap,
60 fmt,
61 path::{Path, PathBuf},
62 sync::Arc,
63 time::{SystemTime, UNIX_EPOCH},
64};
65
66use futures::executor::block_on;
67use keyring_core::{
68 api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
69 attributes::parse_attributes,
70 {Credential, Entry, Error, Result},
71};
72use regex::Regex;
73use turso::{Builder, Connection, Database, Value};
74use uuid::Uuid;
75
76const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
77
78const MAX_NAME_LEN: usize = 1024;
82const MAX_SECRET_LEN: usize = 65536;
83const SCHEMA_VERSION: u32 = 1;
84const BUSY_TIMEOUT_MS: u32 = 5000;
86const OPEN_LOCK_RETRIES: u32 = 60;
88const OPEN_LOCK_BACKOFF_MS: u64 = 20;
89const OPEN_LOCK_BACKOFF_MAX_MS: u64 = 250;
90
91#[derive(Debug, Default, Clone)]
95pub struct EncryptionOpts {
96 pub cipher: String,
97 pub hexkey: String,
98}
99
100#[derive(Debug, Default, Clone)]
102pub struct DbKeyStoreConfig {
103 pub path: PathBuf,
105
106 pub encryption_opts: Option<EncryptionOpts>,
108
109 pub allow_ambiguity: bool,
111
112 pub vfs: Option<String>,
117
118 pub index_always: bool,
121}
122
123pub fn default_path() -> Result<PathBuf> {
125 Ok(match std::env::var("XDG_STATE_HOME") {
126 Ok(d) => PathBuf::from(d),
127 _ => match std::env::var("HOME") {
128 Ok(h) => PathBuf::from(h).join(".local").join("state"),
129 _ => {
130 return Err(Error::Invalid(
131 "path".to_string(),
132 "No default path: set 'path' in Config (or modifiers), or define XDG_STATE_HOME or HOME"
133 .to_string(),
134 ));
135 }
136 },
137 }
138 .join("keystore.db"))
139}
140
141#[derive(Clone)]
142pub struct DbKeyStore {
143 inner: Arc<DbKeyStoreInner>,
144}
145
146#[derive(Debug)]
147struct DbKeyStoreInner {
148 db: Database,
149 id: String,
150 allow_ambiguity: bool,
151 encrypted: bool,
152 path: String,
153}
154
155#[derive(Debug, Clone, Eq, PartialEq, Hash)]
156struct CredId {
157 service: String,
158 user: String,
159}
160
161#[derive(Debug, Clone)]
162struct DbKeyCredential {
163 inner: Arc<DbKeyStoreInner>,
164 id: CredId,
165 uuid: Option<String>,
166 comment: Option<String>,
167}
168
169impl DbKeyStore {
170 pub fn new(config: &DbKeyStoreConfig) -> Result<Arc<DbKeyStore>> {
171 let start_time = SystemTime::now()
172 .duration_since(UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_secs_f64();
175 let (store, conn) = if let Some(vfs) = &config.vfs
176 && vfs == "memory"
177 {
178 let db = map_turso(block_on(async {
180 Builder::new_local(":memory:")
181 .with_io("memory".into())
182 .build()
183 .await
184 }))?;
185 let id = format!("DbKeyStore v{CRATE_VERSION} in-memory @ {start_time}",);
186 let conn = map_turso(db.connect())?;
187 (
188 DbKeyStore {
189 inner: Arc::new(DbKeyStoreInner {
190 db,
191 id,
192 allow_ambiguity: config.allow_ambiguity,
193 encrypted: false,
194 path: ":memory:".to_string(),
195 }),
196 },
197 conn,
198 )
199 } else {
200 let path = if config.path.as_os_str().is_empty() {
201 default_path()?
202 } else {
203 config.path.clone()
204 };
205 ensure_parent_dir(&path)?;
206 let path_str = path.to_str().ok_or_else(|| {
207 Error::Invalid("path".into(), format!("invalid path {}", path.display()))
208 })?;
209 let db =
210 open_db_with_retry(path_str, config.encryption_opts.clone(), config.vfs.clone())?;
211 let conn = retry_turso_locking(|| db.connect())?;
212 configure_connection(&conn)?;
213 let encrypted = config
214 .encryption_opts
215 .as_ref()
216 .is_some_and(|o| !o.cipher.is_empty());
217 let id = format!(
218 "DbKeyStore v{CRATE_VERSION} path:{path_str} enc:{encrypted} @ {start_time}",
219 );
220 (
221 DbKeyStore {
222 inner: Arc::new(DbKeyStoreInner {
223 db,
224 id,
225 allow_ambiguity: config.allow_ambiguity,
226 encrypted,
227 path: path_str.to_string(),
228 }),
229 },
230 conn,
231 )
232 };
233 init_schema(&conn, config.allow_ambiguity, config.index_always)?;
234 Ok(Arc::new(store))
235 }
236
237 pub fn new_with_modifiers(modifiers: &HashMap<&str, &str>) -> Result<Arc<DbKeyStore>> {
238 let mods = parse_attributes(
239 &[
240 "path",
241 "encryption-cipher",
242 "cipher",
243 "encryption-hexkey",
244 "hexkey",
245 "*allow-ambiguity",
246 "*allow_ambiguity",
247 "vfs",
248 "*index-always",
249 "*index_always",
250 ],
251 Some(modifiers),
252 )?;
253 let path = mods.get("path").map(PathBuf::from).unwrap_or_default();
254 let cipher = mods
255 .get("encryption-cipher")
256 .or_else(|| mods.get("cipher"))
257 .cloned();
258 let hexkey = mods
259 .get("encryption-hexkey")
260 .or_else(|| mods.get("hexkey"))
261 .cloned();
262 let allow_ambiguity = mods
263 .get("allow-ambiguity")
264 .or_else(|| mods.get("allow_ambiguity"))
265 .map(|value| value == "true")
266 .unwrap_or(false);
267 let index_always = mods
268 .get("index-always")
269 .or_else(|| mods.get("index_always"))
270 .map(|value| value == "true")
271 .unwrap_or(false);
272 let vfs = mods.get("vfs").cloned();
273 let encryption_opts = match (cipher, hexkey) {
274 (None, None) => None,
275 (Some(cipher), Some(hexkey)) => Some(EncryptionOpts { cipher, hexkey }),
276 _ => {
277 return Err(Error::Invalid(
278 "encryption".to_string(),
279 "encryption-cipher and encryption-hexkey must both be set".to_string(),
280 ));
281 }
282 };
283 let config = DbKeyStoreConfig {
284 path,
285 encryption_opts,
286 allow_ambiguity,
287 vfs,
288 index_always,
289 };
290 DbKeyStore::new(&config)
291 }
292
293 pub fn is_encrypted(&self) -> bool {
295 self.inner.encrypted
296 }
297
298 pub fn path(&self) -> String {
300 self.inner.path.clone()
301 }
302}
303
304impl std::fmt::Debug for DbKeyStore {
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 f.debug_struct("DbKeyStore")
307 .field("id", &self.id())
308 .field("allow_ambiguity", &self.inner.allow_ambiguity)
309 .field("vendor", &self.vendor())
310 .finish()
311 }
312}
313
314impl DbKeyStoreInner {
315 fn connect(&self) -> Result<Connection> {
316 let conn = map_turso(self.db.connect())?;
317 configure_connection(&conn)?;
318 Ok(conn)
319 }
320}
321
322impl DbKeyCredential {
323 async fn insert_credential(
324 &self,
325 conn: &Connection,
326 uuid: &str,
327 secret: Value,
328 comment: Value,
329 ) -> Result<()> {
330 conn.execute(
331 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
332 (
333 self.id.service.as_str(),
334 self.id.user.as_str(),
335 uuid,
336 secret,
337 comment,
338 ),
339 )
340 .await
341 .map_err(map_turso_err)?;
342 Ok(())
343 }
344}
345
346impl CredentialStoreApi for DbKeyStore {
347 fn vendor(&self) -> String {
348 String::from("DbKeyStore, https://crates.io/crates/db-keystore")
349 }
350
351 fn id(&self) -> String {
352 self.inner.id.clone()
353 }
354
355 fn build(
359 &self,
360 service: &str,
361 user: &str,
362 modifiers: Option<&HashMap<&str, &str>>,
363 ) -> Result<Entry> {
364 validate_service_user(service, user)?;
365 let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
366 let credential = DbKeyCredential {
367 inner: Arc::clone(&self.inner),
368 id: CredId {
369 service: service.to_string(),
370 user: user.to_string(),
371 },
372 uuid: mods.get("uuid").cloned(),
373 comment: mods.get("comment").cloned(),
374 };
375 Ok(Entry::new_with_credential(Arc::new(credential)))
376 }
377
378 fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
379 let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
380 let service_re = Regex::new(spec.get("service").map(String::as_str).unwrap_or(""))
381 .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
382 let user_re = Regex::new(spec.get("user").map(String::as_str).unwrap_or(""))
383 .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
384 let comment_re = Regex::new(spec.get("comment").map(String::as_str).unwrap_or(""))
385 .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
386 let uuid_re = Regex::new(spec.get("uuid").map(String::as_str).unwrap_or(""))
387 .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
388 let conn = self.inner.connect()?;
389 let rows = map_turso(block_on(query_all_credentials(&conn)))?;
390 let mut entries = Vec::new();
391 let filter_comment = spec.contains_key("comment");
392 for (id, uuid, comment) in rows {
393 if !service_re.is_match(id.service.as_str()) {
394 continue;
395 }
396 if !user_re.is_match(id.user.as_str()) {
397 continue;
398 }
399 if !uuid_re.is_match(uuid.as_str()) {
400 continue;
401 }
402 if filter_comment {
403 match comment.as_ref() {
404 Some(text) if comment_re.is_match(text.as_str()) => {}
405 _ => continue,
406 }
407 }
408 let credential = DbKeyCredential {
409 inner: Arc::clone(&self.inner),
410 id,
411 uuid: Some(uuid),
412 comment: None,
413 };
414 entries.push(Entry::new_with_credential(Arc::new(credential)));
415 }
416 Ok(entries)
417 }
418
419 fn as_any(&self) -> &dyn std::any::Any {
420 self
421 }
422
423 fn persistence(&self) -> CredentialPersistence {
424 CredentialPersistence::UntilDelete
425 }
426
427 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428 fmt::Debug::fmt(self, f)
429 }
430}
431
432impl CredentialApi for DbKeyCredential {
433 fn set_secret(&self, secret: &[u8]) -> Result<()> {
434 validate_service_user(&self.id.service, &self.id.user)?;
435 validate_secret(secret)?;
436 let make_secret_value = || Value::Blob(secret.to_vec());
437 let make_comment_value = || match self.comment.as_ref() {
438 Some(value) => Value::Text(value.to_string()),
439 None => Value::Null,
440 };
441 let conn = self.inner.connect()?;
442 if self.uuid.is_none() && !self.inner.allow_ambiguity {
443 return map_turso(block_on(async {
444 let uuid = Uuid::new_v4().to_string();
445 conn.execute(
446 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
447 ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
448 (
449 self.id.service.as_str(),
450 self.id.user.as_str(),
451 uuid.as_str(),
452 make_secret_value(),
453 make_comment_value(),
454 ),
455 )
456 .await?;
457 Ok(())
458 }));
459 }
460 block_on(async {
461 conn.execute("BEGIN IMMEDIATE", ())
462 .await
463 .map_err(map_turso_err)?;
464 let result = match &self.uuid {
465 Some(uuid) => {
466 let updated = conn
467 .execute(
468 "UPDATE credentials SET secret = ?1 WHERE uuid = ?2",
469 (make_secret_value(), uuid.as_str()),
470 )
471 .await
472 .map_err(map_turso_err)?;
473 if updated > 0 {
474 Ok(())
475 } else {
476 if !self.inner.allow_ambiguity {
477 let uuids =
478 fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
479 match uuids.len() {
480 0 => {}
481 1 => {
482 if uuids[0] != *uuid {
483 return Err(Error::Invalid(
484 "uuid".to_string(),
485 "credential already exists for service/user"
486 .to_string(),
487 ));
488 }
489 }
490 _ => {
491 return Err(Error::Ambiguous(ambiguous_entries(
492 Arc::clone(&self.inner),
493 &self.id,
494 uuids,
495 )));
496 }
497 }
498 }
499 self.insert_credential(
500 &conn,
501 uuid.as_str(),
502 make_secret_value(),
503 make_comment_value(),
504 )
505 .await?;
506 Ok(())
507 }
508 }
509 None => {
510 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
511 match uuids.len() {
512 0 => {
513 let uuid = Uuid::new_v4().to_string();
514 self.insert_credential(
515 &conn,
516 uuid.as_str(),
517 make_secret_value(),
518 make_comment_value(),
519 )
520 .await?;
521 Ok(())
522 }
523 1 => {
524 conn.execute(
525 "UPDATE credentials SET secret = ?1 WHERE uuid = ?2",
526 (make_secret_value(), uuids[0].as_str()),
527 )
528 .await
529 .map_err(map_turso_err)?;
530 Ok(())
531 }
532 _ => Err(Error::Ambiguous(ambiguous_entries(
533 Arc::clone(&self.inner),
534 &self.id,
535 uuids,
536 ))),
537 }
538 }
539 };
540 match result {
541 Ok(()) => {
542 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
543 Ok(())
544 }
545 Err(err) => {
546 let _ = conn.execute("ROLLBACK", ()).await;
547 Err(err)
548 }
549 }
550 })
551 }
552
553 fn get_secret(&self) -> Result<Vec<u8>> {
554 validate_service_user(&self.id.service, &self.id.user)?;
555 let conn = self.inner.connect()?;
556 match &self.uuid {
557 Some(uuid) => {
558 let credential = map_turso(block_on(fetch_credential_by_uuid(&conn, uuid)))?;
559 match credential {
560 Some((secret, _comment)) => Ok(secret),
561 None => Err(Error::NoEntry),
562 }
563 }
564 None => {
565 let matches = map_turso(block_on(fetch_credentials_by_id(&conn, &self.id)))?;
566 match matches.len() {
567 0 => Err(Error::NoEntry),
568 1 => Ok(matches[0].1.clone()),
569 _ => Err(Error::Ambiguous(ambiguous_entries(
570 Arc::clone(&self.inner),
571 &self.id,
572 matches.into_iter().map(|pair| pair.0).collect(),
573 ))),
574 }
575 }
576 }
577 }
578
579 fn get_attributes(&self) -> Result<HashMap<String, String>> {
580 validate_service_user(&self.id.service, &self.id.user)?;
581 let conn = self.inner.connect()?;
582 match &self.uuid {
583 Some(uuid) => {
584 let credential = map_turso(block_on(fetch_credential_by_uuid(&conn, uuid)))?;
585 match credential {
586 Some((_secret, comment)) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
587 None => Err(Error::NoEntry),
588 }
589 }
590 None => {
591 let matches = map_turso(block_on(fetch_credentials_by_id(&conn, &self.id)))?;
592 match matches.len() {
593 0 => Err(Error::NoEntry),
594 1 => Ok(attributes_for_uuid(
595 matches[0].0.as_str(),
596 matches[0].2.clone(),
597 )),
598 _ => Err(Error::Ambiguous(ambiguous_entries(
599 Arc::clone(&self.inner),
600 &self.id,
601 matches.into_iter().map(|pair| pair.0).collect(),
602 ))),
603 }
604 }
605 }
606 }
607
608 fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
609 parse_attributes(&["comment"], Some(attrs))?;
610 let comment = attrs.get("comment").map(|value| value.to_string());
611 if comment.is_none() {
612 self.get_attributes()?;
613 return Ok(());
614 }
615 let make_comment_value = || match comment.as_ref() {
616 Some(value) => Value::Text(value.to_string()),
617 None => Value::Null,
618 };
619 let conn = self.inner.connect()?;
620 block_on(async {
621 conn.execute("BEGIN IMMEDIATE", ())
622 .await
623 .map_err(map_turso_err)?;
624 let result = match &self.uuid {
625 Some(uuid) => {
626 let updated = conn
627 .execute(
628 "UPDATE credentials SET comment = ?1 WHERE uuid = ?2",
629 (make_comment_value(), uuid.as_str()),
630 )
631 .await
632 .map_err(map_turso_err)?;
633 if updated == 0 {
634 Err(Error::NoEntry)
635 } else {
636 Ok(())
637 }
638 }
639 None if self.inner.allow_ambiguity => {
640 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
641 match uuids.len() {
642 0 => Err(Error::NoEntry),
643 1 => {
644 conn.execute(
645 "UPDATE credentials SET comment = ?1 WHERE uuid = ?2",
646 (make_comment_value(), uuids[0].as_str()),
647 )
648 .await
649 .map_err(map_turso_err)?;
650 Ok(())
651 }
652 _ => Err(Error::Ambiguous(ambiguous_entries(
653 Arc::clone(&self.inner),
654 &self.id,
655 uuids,
656 ))),
657 }
658 }
659 None => {
660 let updated = conn
661 .execute(
662 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
663 (
664 make_comment_value(),
665 self.id.service.as_str(),
666 self.id.user.as_str(),
667 ),
668 )
669 .await
670 .map_err(map_turso_err)?;
671 if updated == 0 {
672 Err(Error::NoEntry)
673 } else {
674 Ok(())
675 }
676 }
677 };
678 match result {
679 Ok(()) => {
680 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
681 Ok(())
682 }
683 Err(err) => {
684 let _ = conn.execute("ROLLBACK", ()).await;
685 Err(err)
686 }
687 }
688 })
689 }
690
691 fn delete_credential(&self) -> Result<()> {
692 validate_service_user(&self.id.service, &self.id.user)?;
693 let conn = self.inner.connect()?;
694 match &self.uuid {
695 Some(uuid) => {
696 let deleted = map_turso(block_on(
697 conn.execute("DELETE FROM credentials WHERE uuid = ?1", (uuid.as_str(),)),
698 ))?;
699 if deleted == 0 {
700 Err(Error::NoEntry)
701 } else {
702 Ok(())
703 }
704 }
705 None => {
706 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
707 match uuids.len() {
708 0 => Err(Error::NoEntry),
709 1 => {
710 map_turso(block_on(conn.execute(
711 "DELETE FROM credentials WHERE uuid = ?1",
712 (uuids[0].as_str(),),
713 )))?;
714 Ok(())
715 }
716 _ => Err(Error::Ambiguous(ambiguous_entries(
717 Arc::clone(&self.inner),
718 &self.id,
719 uuids,
720 ))),
721 }
722 }
723 }
724 }
725
726 fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
727 validate_service_user(&self.id.service, &self.id.user)?;
728 let conn = self.inner.connect()?;
729 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
730 match uuids.len() {
731 0 => Err(Error::NoEntry),
732 1 => Ok(Some(Arc::new(DbKeyCredential {
733 inner: Arc::clone(&self.inner),
734 id: self.id.clone(),
735 uuid: Some(uuids[0].clone()),
736 comment: None,
737 }))),
738 _ => Err(Error::Ambiguous(ambiguous_entries(
739 Arc::clone(&self.inner),
740 &self.id,
741 uuids,
742 ))),
743 }
744 }
745
746 fn get_specifiers(&self) -> Option<(String, String)> {
747 Some((self.id.service.clone(), self.id.user.clone()))
748 }
749
750 fn as_any(&self) -> &dyn std::any::Any {
751 self
752 }
753
754 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
755 fmt::Debug::fmt(self, f)
756 }
757}
758
759fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
760 map_turso(block_on(conn.execute(
761 "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
762 (),
763 )))?;
764 map_turso(block_on(conn.execute(
765 "CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
766 (),
767 )))?;
768 ensure_schema_version(conn)?;
769 if !allow_ambiguity {
770 map_turso(block_on(conn.execute(
772 "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
773 (),
774 )))?;
775 } else if index_always {
776 map_turso(block_on(conn.execute(
781 "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
782 (),
783 )))?;
784 }
785 Ok(())
786}
787
788fn ensure_schema_version(conn: &Connection) -> Result<()> {
789 map_turso(block_on(async {
790 let mut rows = conn
791 .query(
792 "SELECT value FROM keystore_meta WHERE key = 'schema_version'",
793 (),
794 )
795 .await?;
796 if let Some(row) = rows.next().await? {
797 let value = value_to_string(row.get_value(0)?, "schema_version")?;
798 let version = value.parse::<u32>().map_err(|_| {
799 turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
800 })?;
801 if version != SCHEMA_VERSION {
802 return Err(turso::Error::ConversionFailure(format!(
803 "unsupported schema version: {version}"
804 )));
805 }
806 } else {
807 conn.execute(
808 "INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
809 (SCHEMA_VERSION.to_string(),),
810 )
811 .await?;
812 }
813 Ok(())
814 }))
815}
816
817async fn query_all_credentials(
818 conn: &Connection,
819) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
820 let mut rows = conn
821 .query("SELECT service, user, uuid, comment FROM credentials", ())
822 .await?;
823 let mut results = Vec::new();
824 while let Some(row) = rows.next().await? {
825 let service = value_to_string(row.get_value(0)?, "service")?;
826 let user = value_to_string(row.get_value(1)?, "user")?;
827 let uuid = value_to_string(row.get_value(2)?, "uuid")?;
828 let comment = value_to_option_string(row.get_value(3)?, "comment")?;
829 results.push((CredId { service, user }, uuid, comment));
830 }
831 Ok(results)
832}
833
834async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
835 let mut rows = conn
836 .query(
837 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
838 (id.service.as_str(), id.user.as_str()),
839 )
840 .await?;
841 let mut uuids = Vec::new();
842 while let Some(row) = rows.next().await? {
843 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
844 uuids.push(uuid);
845 }
846 Ok(uuids)
847}
848
849async fn fetch_credential_by_uuid(
850 conn: &Connection,
851 uuid: &str,
852) -> turso::Result<Option<(Vec<u8>, Option<String>)>> {
853 let mut rows = conn
854 .query(
855 "SELECT secret, comment FROM credentials WHERE uuid = ?1",
856 (uuid,),
857 )
858 .await?;
859 let Some(row) = rows.next().await? else {
860 return Ok(None);
861 };
862 let secret = value_to_bytes(row.get_value(0)?, "secret")?;
863 let comment = value_to_option_string(row.get_value(1)?, "comment")?;
864 Ok(Some((secret, comment)))
865}
866
867async fn fetch_credentials_by_id(
868 conn: &Connection,
869 id: &CredId,
870) -> turso::Result<Vec<(String, Vec<u8>, Option<String>)>> {
871 let mut rows = conn
872 .query(
873 "SELECT uuid, secret, comment FROM credentials WHERE service = ?1 AND user = ?2",
874 (id.service.as_str(), id.user.as_str()),
875 )
876 .await?;
877 let mut results = Vec::new();
878 while let Some(row) = rows.next().await? {
879 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
880 let secret = value_to_bytes(row.get_value(1)?, "secret")?;
881 let comment = value_to_option_string(row.get_value(2)?, "comment")?;
882 results.push((uuid, secret, comment));
883 }
884 Ok(results)
885}
886
887fn ambiguous_entries(inner: Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
888 uuids
889 .into_iter()
890 .map(|uuid| {
891 Entry::new_with_credential(Arc::new(DbKeyCredential {
892 inner: Arc::clone(&inner),
893 id: id.clone(),
894 uuid: Some(uuid),
895 comment: None,
896 }))
897 })
898 .collect()
899}
900
901fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
902 let mut attrs = HashMap::new();
903 attrs.insert("uuid".to_string(), uuid.to_string());
904 if let Some(comment) = comment {
905 attrs.insert("comment".to_string(), comment);
906 }
907 attrs
908}
909
910fn configure_connection(conn: &Connection) -> Result<()> {
914 map_turso(block_on(async {
915 let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
916 let _ = rows.next().await?;
917 let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
918 conn.execute(busy_stmt.as_str(), ()).await?;
919 Ok(())
920 }))
921}
922
923fn open_db_with_retry(
925 path_str: &str,
926 encryption_opts: Option<EncryptionOpts>,
927 vfs: Option<String>,
928) -> Result<Database> {
929 let mut retries = OPEN_LOCK_RETRIES;
930 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
931 loop {
932 let mut builder = Builder::new_local(path_str);
933 if let Some(opts) = encryption_opts.clone() {
934 let turso_enc_opts = turso::EncryptionOpts {
935 cipher: opts.cipher,
936 hexkey: opts.hexkey,
937 };
938 builder = builder
939 .experimental_encryption(true)
940 .with_encryption(turso_enc_opts);
941 }
942 if let Some(vfs) = vfs.clone() {
943 builder = builder.with_io(vfs);
944 }
945 match block_on(builder.build()) {
946 Ok(db) => return Ok(db),
947 Err(err) => {
948 if retries == 0 || !is_turso_locking_error(&err) {
949 return Err(map_turso_err(err));
950 }
951 retries -= 1;
952 let nanos = SystemTime::now()
953 .duration_since(UNIX_EPOCH)
954 .unwrap_or_default()
955 .subsec_nanos();
956 let jitter = (nanos % 20) as u64;
957 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
958 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
959 }
960 }
961 }
962}
963
964fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
965 let mut retries = OPEN_LOCK_RETRIES;
966 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
967 loop {
968 match op() {
969 Ok(value) => return Ok(value),
970 Err(err) => {
971 if retries == 0 || !is_turso_locking_error(&err) {
972 return Err(map_turso_err(err));
973 }
974 retries -= 1;
975 let nanos = SystemTime::now()
976 .duration_since(UNIX_EPOCH)
977 .unwrap_or_default()
978 .subsec_nanos();
979 let jitter = (nanos % 20) as u64;
980 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
981 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
982 }
983 }
984 }
985}
986
987fn is_turso_locking_error(err: &turso::Error) -> bool {
988 let text = err.to_string().to_lowercase();
989 text.contains("locking error")
990 || text.contains("file is locked")
991 || text.contains("database is locked")
992 || text.contains("database is busy")
993 || text.contains("sqlite_busy")
994 || text.contains("sqlite_locked")
995}
996
997fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
998 match value {
999 Value::Text(text) => Ok(text),
1000 Value::Blob(blob) => String::from_utf8(blob)
1001 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1002 other => Err(turso::Error::ConversionFailure(format!(
1003 "unexpected value for {field}: {other:?}"
1004 ))),
1005 }
1006}
1007
1008fn value_to_bytes(value: Value, field: &str) -> turso::Result<Vec<u8>> {
1009 match value {
1010 Value::Blob(blob) => Ok(blob),
1011 Value::Text(text) => Ok(text.into_bytes()),
1012 other => Err(turso::Error::ConversionFailure(format!(
1013 "unexpected value for {field}: {other:?}"
1014 ))),
1015 }
1016}
1017
1018fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
1019 match value {
1020 Value::Null => Ok(None),
1021 Value::Text(text) => Ok(Some(text)),
1022 Value::Blob(blob) => String::from_utf8(blob)
1023 .map(Some)
1024 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
1025 other => Err(turso::Error::ConversionFailure(format!(
1026 "unexpected value for {field}: {other:?}"
1027 ))),
1028 }
1029}
1030
1031fn ensure_parent_dir(path: &Path) -> Result<()> {
1032 let parent = path
1033 .parent()
1034 .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
1035 if parent.as_os_str().is_empty() {
1036 return Ok(());
1037 }
1038 std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
1039}
1040
1041fn validate_service_user(service: &str, user: &str) -> Result<()> {
1043 if service.is_empty() {
1044 return Err(Error::Invalid(
1045 "service".to_string(),
1046 "service is empty".to_string(),
1047 ));
1048 }
1049 if user.is_empty() {
1050 return Err(Error::Invalid(
1051 "user".to_string(),
1052 "user is empty".to_string(),
1053 ));
1054 }
1055 if service.len() > MAX_NAME_LEN {
1056 return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN as u32));
1057 }
1058 if user.len() > MAX_NAME_LEN {
1059 return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN as u32));
1060 }
1061 Ok(())
1062}
1063
1064fn validate_secret(secret: &[u8]) -> Result<()> {
1066 if secret.len() > MAX_SECRET_LEN {
1067 return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN as u32));
1068 }
1069 Ok(())
1070}
1071
1072fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
1073 result.map_err(map_turso_err)
1074}
1075
1076fn map_turso_err(err: turso::Error) -> Error {
1077 Error::PlatformFailure(Box::new(err))
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083
1084 fn new_store(path: &Path) -> Arc<DbKeyStore> {
1085 let config = DbKeyStoreConfig {
1086 path: path.to_path_buf(),
1087 ..Default::default()
1088 };
1089 DbKeyStore::new(&config).expect("failed to create store")
1090 }
1091
1092 fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
1093 store
1094 .build(service, user, None)
1095 .expect("failed to build entry")
1096 }
1097
1098 #[test]
1100 fn create_store_creates_parent_dir() {
1101 let dir = tempfile::tempdir().expect("tempdir");
1102 let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
1103 let parent = db_path.parent().expect("parent");
1104 assert!(!parent.exists());
1105
1106 let config = DbKeyStoreConfig {
1107 path: db_path.clone(),
1108 ..Default::default()
1109 };
1110 let store = DbKeyStore::new(&config).expect("create store");
1111 assert!(parent.is_dir());
1112
1113 let entry = build_entry(&store, "demo", "alice");
1114 entry.set_password("dromomeryx").expect("set_password");
1115 }
1116
1117 #[test]
1119 fn set_password_then_search_finds_password() {
1120 let dir = tempfile::tempdir().expect("tempdir");
1121 let path = dir.path().join("keystore.db");
1122 let store = new_store(&path);
1123 let entry = build_entry(&store, "demo", "alice");
1124 entry.set_password("dromomeryx").expect("set_password");
1125
1126 let mut spec = HashMap::new();
1127 spec.insert("service", "demo");
1128 spec.insert("user", "alice");
1129 let results = store.search(&spec).expect("search");
1130 assert_eq!(results.len(), 1);
1131 assert_eq!(results[0].get_password().unwrap(), "dromomeryx");
1132 }
1133
1134 #[test]
1136 fn comment_attributes_round_trip() {
1137 let dir = tempfile::tempdir().expect("tempdir");
1138 let path = dir.path().join("keystore.db");
1139 let store = new_store(&path);
1140 let entry = build_entry(&store, "demo", "alice");
1141 entry.set_password("dromomeryx").expect("set_password");
1142
1143 let update = HashMap::from([("comment", "note")]);
1144 entry.update_attributes(&update).expect("update_attributes");
1145 let attrs = entry.get_attributes().expect("get_attributes");
1146 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1147 assert!(attrs.contains_key("uuid"));
1148
1149 let mut spec = HashMap::new();
1150 spec.insert("service", "demo");
1151 spec.insert("user", "alice");
1152 spec.insert("comment", "note");
1153 let results = store.search(&spec).expect("search");
1154 assert_eq!(results.len(), 1);
1155
1156 let uuid = attrs.get("uuid").cloned().expect("get uuid");
1157 let mut spec = HashMap::new();
1158 spec.insert("service", "demo");
1159 spec.insert("user", "alice");
1160 spec.insert("uuid", uuid.as_str());
1161 let results = store.search(&spec).expect("search");
1162 assert_eq!(results.len(), 1);
1163 }
1164
1165 #[test]
1166 fn comment_with_password_round_trip() {
1167 let dir = tempfile::tempdir().expect("tempdir");
1168 let path = dir.path().join("keystore.db");
1169 let store = new_store(&path);
1170 let entry = build_entry(&store, "demo", "alice");
1171 entry.set_password("dromomeryx").expect("set_password");
1172
1173 let update = HashMap::from([("comment", "note")]);
1175 entry.update_attributes(&update).expect("update_attributes");
1176
1177 let mut spec = HashMap::new();
1179 spec.insert("service", "demo");
1180 spec.insert("user", "alice");
1181 spec.insert("comment", "note");
1182 let results = store.search(&spec).expect("search");
1183 assert_eq!(results.len(), 1);
1184
1185 let found = &results[0];
1186 assert_eq!(
1187 found.get_password().expect("password with comment"),
1188 "dromomeryx"
1189 );
1190 let attrs = found.get_attributes().expect("get_attributes");
1191 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1192 assert!(attrs.contains_key("uuid"));
1193 }
1194
1195 #[test]
1196 fn build_with_comment_modifier_sets_comment() -> Result<()> {
1197 let dir = tempfile::tempdir().expect("tempdir");
1198 let path = dir.path().join("keystore.db");
1199 let store = new_store(&path);
1200 let entry = store.build(
1201 "demo",
1202 "alice",
1203 Some(&HashMap::from([("comment", "initial")])),
1204 )?;
1205 entry.set_password("dromomeryx")?;
1206
1207 let attrs = entry.get_attributes()?;
1208 assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
1209 Ok(())
1210 }
1211
1212 #[test]
1213 fn in_memory_store_round_trip() -> Result<()> {
1214 let config = DbKeyStoreConfig {
1215 vfs: Some("memory".to_string()),
1216 ..Default::default()
1217 };
1218 let store = DbKeyStore::new(&config)?;
1219 let entry = build_entry(&store, "demo", "alice");
1220 entry.set_password("dromomeryx")?;
1221
1222 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1223 assert_eq!(results.len(), 1);
1224 assert_eq!(results[0].get_password()?, "dromomeryx");
1225 Ok(())
1226 }
1227
1228 #[test]
1230 fn stores_separate_service_user_pairs() -> Result<()> {
1231 let dir = tempfile::tempdir().expect("tempdir");
1232 let path = dir.path().join("keystore.db");
1233 let store = new_store(&path);
1234
1235 build_entry(&store, "myapp", "user1").set_password("pw1")?;
1236 build_entry(&store, "myapp", "user2").set_password("pw2")?;
1237 build_entry(&store, "myapp", "user3").set_password("pw3")?;
1238
1239 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
1240 assert_eq!(results.len(), 1);
1241 assert_eq!(results[0].get_password()?, "pw1");
1242
1243 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
1244 assert_eq!(results.len(), 1);
1245 assert_eq!(results[0].get_password()?, "pw2");
1246
1247 let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
1248 assert_eq!(results.len(), 1);
1249 assert_eq!(results[0].get_password()?, "pw3");
1250 Ok(())
1251 }
1252
1253 #[test]
1255 fn search_regex() -> Result<()> {
1256 let dir = tempfile::tempdir().expect("tempdir");
1257 let path = dir.path().join("keystore.db");
1258 let store = new_store(&path);
1259
1260 build_entry(&store, "myapp", "user1").set_password("pw1")?;
1261 build_entry(&store, "myapp", "user2").set_password("pw2")?;
1262 build_entry(&store, "myapp", "user3").set_password("pw3")?;
1263 build_entry(&store, "other-app", "user1").set_password("pw4")?;
1264
1265 let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
1267 assert_eq!(results.len(), 2, "search *app, user1");
1268
1269 let results = store.search(&HashMap::from([
1271 ("service", "myapp"),
1272 ("user", "user1|user2"),
1273 ]))?;
1274 assert_eq!(results.len(), 2, "search regex OR");
1275
1276 Ok(())
1277 }
1278
1279 #[test]
1281 fn search_partial() -> Result<()> {
1282 let dir = tempfile::tempdir().expect("tempdir");
1283 let path = dir.path().join("keystore.db");
1284 let store = new_store(&path);
1285
1286 let results = store.search(&HashMap::new())?;
1288 assert_eq!(results.len(), 0, "empty db, no results");
1289
1290 build_entry(&store, "myapp", "user1").set_password("pw1")?;
1291 build_entry(&store, "other-app", "user1").set_password("pw2")?;
1292
1293 let results = store.search(&HashMap::new())?;
1295 assert_eq!(results.len(), 2, "search, empty hashmap");
1296
1297 let results = store.search(&HashMap::from([("service", "myapp")]))?;
1299 assert_eq!(results.len(), 1, "search myapp");
1300
1301 let results = store.search(&HashMap::from([("user", "user1")]))?;
1303 assert_eq!(results.len(), 2, "search user1");
1304 Ok(())
1305 }
1306
1307 #[test]
1309 fn repeated_set_replaces_secret() {
1310 let dir = tempfile::tempdir().expect("tempdir");
1311 let path = dir.path().join("keystore.db");
1312 let store = new_store(&path);
1313 let entry = build_entry(&store, "demo", "alice");
1314 entry.set_password("first").expect("password set 1");
1315 entry.set_secret(b"second").expect("password set 2");
1316
1317 let mut spec = HashMap::new();
1318 spec.insert("service", "demo");
1319 spec.insert("user", "alice");
1320 let results = store.search(&spec).expect("search");
1321 assert_eq!(results.len(), 1);
1322 assert_eq!(
1323 results[0].get_password().expect("get first password"),
1324 "second",
1325 "second password overwrites first"
1326 );
1327 }
1328
1329 #[test]
1330 fn same_service_user_entries_share_credential() -> Result<()> {
1331 let dir = tempfile::tempdir().expect("tempdir");
1332 let path = dir.path().join("keystore.db");
1333 let store = new_store(&path);
1334 let entry1 = build_entry(&store, "demo", "alice");
1335 let entry2 = build_entry(&store, "demo", "alice");
1336
1337 entry1.set_password("first")?;
1338 assert_eq!(entry2.get_password()?, "first");
1339
1340 entry2.set_password("second")?;
1341 assert_eq!(entry1.get_password()?, "second");
1342 Ok(())
1343 }
1344
1345 #[test]
1347 fn remove_returns_no_entry() {
1348 let dir = tempfile::tempdir().expect("tempdir");
1349 let path = dir.path().join("keystore.db");
1350 let store = new_store(&path);
1351 let entry = build_entry(&store, "demo", "alice");
1352 entry.set_password("dromomeryx").expect("set password");
1353 entry.delete_credential().expect("delete credential");
1354 let err = entry.delete_credential().unwrap_err();
1355 assert!(matches!(err, Error::NoEntry));
1356 }
1357
1358 #[test]
1360 fn remove_clears_secret() {
1361 let dir = tempfile::tempdir().expect("tempdir");
1362 let path = dir.path().join("keystore.db");
1363 let store = new_store(&path);
1364 let entry = build_entry(&store, "service", "user");
1365 entry.set_password("dromomeryx").expect("set password");
1366 entry.delete_credential().expect("delete credential");
1367
1368 let mut spec = HashMap::new();
1369 spec.insert("service", "demo");
1370 spec.insert("user", "alice");
1371 let results = store.search(&spec).expect("search");
1372 assert!(results.is_empty());
1373 }
1374
1375 #[test]
1376 fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
1377 let dir = tempfile::tempdir().expect("tempdir");
1378 let path = dir.path().join("keystore.db");
1379 let config = DbKeyStoreConfig {
1380 path: path.clone(),
1381 allow_ambiguity: true,
1382 ..Default::default()
1383 };
1384 let store = DbKeyStore::new(&config)?;
1385
1386 let uuid1 = Uuid::new_v4().to_string();
1387 let uuid2 = Uuid::new_v4().to_string();
1388 let entry1 = store.build(
1389 "demo",
1390 "alice",
1391 Some(&HashMap::from([
1392 ("uuid", uuid1.as_str()),
1393 ("comment", "one"),
1394 ])),
1395 )?;
1396 let entry2 = store.build(
1397 "demo",
1398 "alice",
1399 Some(&HashMap::from([
1400 ("uuid", uuid2.as_str()),
1401 ("comment", "two"),
1402 ])),
1403 )?;
1404 entry1.set_password("first")?;
1405 entry2.set_password("second")?;
1406
1407 let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
1408 assert_eq!(results.len(), 2);
1409
1410 let entry3 = build_entry(&store, "demo", "alice");
1411 let err = entry3.get_password().unwrap_err();
1412 assert!(matches!(err, Error::Ambiguous(_)));
1413 Ok(())
1414 }
1415
1416 #[test]
1417 fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
1418 let dir = tempfile::tempdir().expect("tempdir");
1419 let path = dir.path().join("keystore.db");
1420 let store = new_store(&path);
1421
1422 let uuid1 = Uuid::new_v4().to_string();
1423 let uuid2 = Uuid::new_v4().to_string();
1424 let entry1 = store.build(
1425 "demo",
1426 "alice",
1427 Some(&HashMap::from([("uuid", uuid1.as_str())])),
1428 )?;
1429 let entry2 = store.build(
1430 "demo",
1431 "alice",
1432 Some(&HashMap::from([("uuid", uuid2.as_str())])),
1433 )?;
1434
1435 entry1.set_password("first")?;
1436 let err = entry2.set_password("second").unwrap_err();
1437 assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
1438 Ok(())
1439 }
1440
1441 #[test]
1442 fn impl_debug() -> Result<()> {
1443 let dir = tempfile::tempdir().expect("tempdir");
1444
1445 let path = dir.path().join("keystore1.db");
1446 let store = new_store(&path);
1447 eprintln!("basic: {store:?}");
1448
1449 let path = dir.path().join("keystore2.db");
1450 let config = DbKeyStoreConfig {
1451 path: path.to_path_buf(),
1452 encryption_opts: Some(EncryptionOpts {
1453 cipher: "aes256gcm".into(),
1454 hexkey: "0000000011111111222222223333333344444444555555556666666677777777".into(),
1455 }),
1456 ..Default::default()
1457 };
1458 let store = DbKeyStore::new(&config)?;
1459 eprintln!("with_enc: {store:?}");
1460
1461 let config = DbKeyStoreConfig {
1462 vfs: Some("memory".to_string()),
1463 ..Default::default()
1464 };
1465 let store = DbKeyStore::new(&config)?;
1466 eprintln!("memory: {store:?}");
1467 Ok(())
1468 }
1469}