1use std::{
54 collections::HashMap,
55 fmt,
56 path::{Path, PathBuf},
57 sync::Arc,
58 time::{SystemTime, UNIX_EPOCH},
59};
60
61use futures::executor::block_on;
62use keyring_core::{
63 api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
64 attributes::parse_attributes,
65 {Credential, Entry, Error, Result},
66};
67use regex::Regex;
68use turso::{Builder, Connection, Database, Value};
69use uuid::Uuid;
70
71const MAX_NAME_LEN: usize = 128;
75const MAX_SECRET_LEN: usize = 8196;
76const BUSY_TIMEOUT_MS: u32 = 5000;
78const OPEN_LOCK_RETRIES: u32 = 60;
80const OPEN_LOCK_BACKOFF_MS: u64 = 20;
81const OPEN_LOCK_BACKOFF_MAX_MS: u64 = 250;
82
83#[derive(Debug, Default, Clone)]
87pub struct EncryptionOpts {
88 pub cipher: String,
89 pub hexkey: String,
90}
91
92#[derive(Debug, Default, Clone)]
94pub struct DbKeyStoreConfig {
95 pub path: PathBuf,
97 pub encryption_opts: Option<EncryptionOpts>,
99 pub allow_ambiguity: bool,
101 pub vfs: Option<String>,
103 pub index_always: bool,
106}
107
108pub fn default_path() -> Result<PathBuf> {
110 Ok(match std::env::var("XDG_STATE_HOME") {
111 Ok(d) => PathBuf::from(d),
112 _ => match std::env::var("HOME") {
113 Ok(h) => PathBuf::from(h).join(".local").join(".state"),
114 _ => {
115 return Err(Error::Invalid(
116 "path".to_string(),
117 "No default path: set 'path' in Config (or modifiers), or define XDG_STATE_HOME or HOME"
118 .to_string(),
119 ));
120 }
121 },
122 }
123 .join("keystore.db"))
124}
125
126#[derive(Debug, Clone)]
127pub struct DbKeyStore {
128 inner: Arc<DbKeyStoreInner>,
129}
130
131#[derive(Debug)]
132struct DbKeyStoreInner {
133 db: Database,
134 id: String,
135 allow_ambiguity: bool,
136 encrypted: bool,
137 path: PathBuf,
138}
139
140#[derive(Debug, Clone, Eq, PartialEq, Hash)]
141struct CredId {
142 service: String,
143 user: String,
144}
145
146#[derive(Debug, Clone)]
147struct DbKeyCredential {
148 inner: Arc<DbKeyStoreInner>,
149 id: CredId,
150 uuid: Option<String>,
151}
152
153impl DbKeyStore {
154 pub fn new(config: &DbKeyStoreConfig) -> Result<DbKeyStore> {
155 let path = if config.path.as_os_str().is_empty() {
156 default_path()?
157 } else {
158 config.path.clone()
159 };
160 ensure_parent_dir(&path)?;
161 let path_str = path.to_str().ok_or_else(|| {
162 Error::Invalid("path".into(), format!("invalid path {}", path.display()))
163 })?;
164 let db = open_db_with_retry(path_str, config.encryption_opts.clone(), config.vfs.clone())?;
165 let conn = retry_turso_locking(|| db.connect())?;
166 configure_connection(&conn)?;
167 init_schema(&conn, config.allow_ambiguity, config.index_always)?;
168 let encrypted = config
169 .encryption_opts
170 .as_ref()
171 .is_some_and(|o| !o.cipher.is_empty());
172 let start_time = SystemTime::now()
173 .duration_since(UNIX_EPOCH)
174 .unwrap_or_default()
175 .as_secs_f64();
176 let id = format!(
177 "DbKeyStore v{} path:{path_str} enc:{encrypted} @ {start_time}",
178 env!("CARGO_PKG_VERSION"),
179 );
180 Ok(DbKeyStore {
181 inner: Arc::new(DbKeyStoreInner {
182 db,
183 id,
184 allow_ambiguity: config.allow_ambiguity,
185 encrypted,
186 path,
187 }),
188 })
189 }
190
191 pub fn new_with_modifiers(modifiers: &HashMap<&str, &str>) -> Result<DbKeyStore> {
192 let mut path: Option<PathBuf> = None;
193 let mut cipher: Option<String> = None;
194 let mut hexkey: Option<String> = None;
195 let mut allow_ambiguity: Option<bool> = None;
196 let mut vfs: Option<String> = None;
197 let mut index_always: Option<bool> = None;
198 for (key, value) in modifiers {
199 match *key {
200 "path" => path = Some(PathBuf::from(value)),
201 "encryption-cipher" | "cipher" => cipher = Some((*value).to_string()),
202 "encryption-hexkey" | "hexkey" => hexkey = Some((*value).to_string()),
203 "allow-ambiguity" | "allow_ambiguity" => {
204 allow_ambiguity = Some(parse_bool_modifier(key, value)?);
205 }
206 "vfs" => vfs = Some((*value).to_string()),
207 "index-always" | "index_always" => {
208 index_always = Some(parse_bool_modifier(key, value)?);
209 }
210 _ => {
211 return Err(Error::Invalid(
212 "modifiers".to_string(),
213 format!("unsupported modifier: {key}"),
214 ));
215 }
216 }
217 }
218 let path = path.unwrap_or_default();
219 let encryption_opts = match (cipher, hexkey) {
220 (None, None) => None,
221 (Some(cipher), Some(hexkey)) => Some(EncryptionOpts { cipher, hexkey }),
222 _ => {
223 return Err(Error::Invalid(
224 "encryption".to_string(),
225 "encryption-cipher and encryption-hexkey must both be set".to_string(),
226 ));
227 }
228 };
229 let config = DbKeyStoreConfig {
230 path,
231 encryption_opts,
232 allow_ambiguity: allow_ambiguity.unwrap_or(false),
233 vfs,
234 index_always: index_always.unwrap_or(false),
235 };
236 DbKeyStore::new(&config)
237 }
238
239 pub fn path(&self) -> &Path {
241 self.inner.path.as_path()
242 }
243
244 pub fn is_encrypted(&self) -> bool {
246 self.inner.encrypted
247 }
248}
249
250impl DbKeyStoreInner {
251 fn connect(&self) -> Result<Connection> {
252 let conn = map_turso(self.db.connect())?;
253 configure_connection(&conn)?;
254 Ok(conn)
255 }
256}
257
258impl CredentialStoreApi for DbKeyStore {
259 fn vendor(&self) -> String {
260 String::from("DbKeyStore, https://crates.io/crates/db-keystore")
261 }
262
263 fn id(&self) -> String {
264 self.inner.id.clone()
265 }
266
267 fn build(
268 &self,
269 service: &str,
270 user: &str,
271 modifiers: Option<&HashMap<&str, &str>>,
272 ) -> Result<Entry> {
273 validate_service_user(service, user)?;
274 if let Some(mods) = modifiers
275 && !mods.is_empty()
276 {
277 return Err(Error::Invalid(
278 "modifiers".to_string(),
279 "modifiers are not supported".to_string(),
280 ));
281 }
282 let credential = DbKeyCredential {
283 inner: Arc::clone(&self.inner),
284 id: CredId {
285 service: service.to_string(),
286 user: user.to_string(),
287 },
288 uuid: None,
289 };
290 Ok(Entry::new_with_credential(Arc::new(credential)))
291 }
292
293 fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
294 validate_search_spec(spec)?;
295 let service_re = Regex::new(spec.get("service").unwrap_or(&""))
296 .map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
297 let user_re = Regex::new(spec.get("user").unwrap_or(&""))
298 .map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
299 let comment_re = Regex::new(spec.get("comment").unwrap_or(&""))
300 .map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
301 let uuid_re = Regex::new(spec.get("uuid").unwrap_or(&""))
302 .map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
303 let conn = self.inner.connect()?;
304 let rows = map_turso(block_on(query_all_credentials(&conn)))?;
305 let mut entries = Vec::new();
306 let filter_comment = spec.get("comment").is_some();
307 for (id, uuid, comment) in rows {
308 if !service_re.is_match(id.service.as_str()) {
309 continue;
310 }
311 if !user_re.is_match(id.user.as_str()) {
312 continue;
313 }
314 if !uuid_re.is_match(uuid.as_str()) {
315 continue;
316 }
317 if filter_comment {
318 match comment.as_ref() {
319 Some(text) if comment_re.is_match(text.as_str()) => {}
320 _ => continue,
321 }
322 }
323 let credential = DbKeyCredential {
324 inner: Arc::clone(&self.inner),
325 id,
326 uuid: Some(uuid),
327 };
328 entries.push(Entry::new_with_credential(Arc::new(credential)));
329 }
330 Ok(entries)
331 }
332
333 fn as_any(&self) -> &dyn std::any::Any {
334 self
335 }
336
337 fn persistence(&self) -> CredentialPersistence {
338 CredentialPersistence::UntilDelete
339 }
340
341 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 fmt::Debug::fmt(self, f)
343 }
344}
345
346impl CredentialApi for DbKeyCredential {
347 fn set_secret(&self, secret: &[u8]) -> Result<()> {
348 validate_service_user(&self.id.service, &self.id.user)?;
349 let secret = validate_secret(secret)?;
350 let conn = self.inner.connect()?;
351 if self.uuid.is_none() && !self.inner.allow_ambiguity {
352 return map_turso(block_on(async {
353 let uuid = Uuid::new_v4().to_string();
354 let comment = Value::Null;
355 conn.execute(
356 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
357 ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
358 (
359 self.id.service.as_str(),
360 self.id.user.as_str(),
361 uuid.as_str(),
362 secret.as_str(),
363 comment,
364 ),
365 )
366 .await?;
367 Ok(())
368 }));
369 }
370 block_on(async {
371 conn.execute("BEGIN IMMEDIATE", ())
372 .await
373 .map_err(map_turso_err)?;
374 let result = match &self.uuid {
375 Some(uuid) => {
376 let updated = conn
377 .execute(
378 "UPDATE credentials SET secret = ?1 WHERE uuid = ?2",
379 (secret.as_str(), uuid.as_str()),
380 )
381 .await
382 .map_err(map_turso_err)?;
383 if updated == 0 {
384 Err(Error::NoEntry)
385 } else {
386 Ok(())
387 }
388 }
389 None => {
390 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
391 match uuids.len() {
392 0 => {
393 let uuid = Uuid::new_v4().to_string();
394 let comment = Value::Null;
395 conn.execute(
396 "INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
397 (
398 self.id.service.as_str(),
399 self.id.user.as_str(),
400 uuid.as_str(),
401 secret.as_str(),
402 comment,
403 ),
404 )
405 .await
406 .map_err(map_turso_err)?;
407 Ok(())
408 }
409 1 => {
410 conn.execute(
411 "UPDATE credentials SET secret = ?1 WHERE uuid = ?2",
412 (secret.as_str(), uuids[0].as_str()),
413 )
414 .await
415 .map_err(map_turso_err)?;
416 Ok(())
417 }
418 _ => Err(Error::Ambiguous(ambiguous_entries(
419 Arc::clone(&self.inner),
420 &self.id,
421 uuids,
422 ))),
423 }
424 }
425 };
426 match result {
427 Ok(()) => {
428 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
429 Ok(())
430 }
431 Err(err) => {
432 let _ = conn.execute("ROLLBACK", ()).await;
433 Err(err)
434 }
435 }
436 })
437 }
438
439 fn get_secret(&self) -> Result<Vec<u8>> {
440 validate_service_user(&self.id.service, &self.id.user)?;
441 let conn = self.inner.connect()?;
442 match &self.uuid {
443 Some(uuid) => {
444 let secret = map_turso(block_on(fetch_secret_by_uuid(&conn, uuid)))?;
445 match secret {
446 Some(secret) => Ok(secret.into_bytes()),
447 None => Err(Error::NoEntry),
448 }
449 }
450 None => {
451 let matches = map_turso(block_on(fetch_secrets_by_id(&conn, &self.id)))?;
452 match matches.len() {
453 0 => Err(Error::NoEntry),
454 1 => Ok(matches[0].1.clone().into_bytes()),
455 _ => Err(Error::Ambiguous(ambiguous_entries(
456 Arc::clone(&self.inner),
457 &self.id,
458 matches.into_iter().map(|pair| pair.0).collect(),
459 ))),
460 }
461 }
462 }
463 }
464
465 fn get_attributes(&self) -> Result<HashMap<String, String>> {
466 validate_service_user(&self.id.service, &self.id.user)?;
467 let conn = self.inner.connect()?;
468 match &self.uuid {
469 Some(uuid) => {
470 let comment = map_turso(block_on(fetch_comment_by_uuid(&conn, uuid)))?;
471 match comment {
472 Some(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
473 None => Err(Error::NoEntry),
474 }
475 }
476 None => {
477 let matches = map_turso(block_on(fetch_comments_by_id(&conn, &self.id)))?;
478 match matches.len() {
479 0 => Err(Error::NoEntry),
480 1 => Ok(attributes_for_uuid(
481 matches[0].0.as_str(),
482 matches[0].1.clone(),
483 )),
484 _ => Err(Error::Ambiguous(ambiguous_entries(
485 Arc::clone(&self.inner),
486 &self.id,
487 matches.into_iter().map(|pair| pair.0).collect(),
488 ))),
489 }
490 }
491 }
492 }
493
494 fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
495 parse_attributes(&["comment"], Some(attrs))?;
496 let comment = attrs.get("comment").map(|value| value.to_string());
497 if comment.is_none() {
498 self.get_attributes()?;
499 return Ok(());
500 }
501 let make_comment_value = || match comment.as_ref() {
502 Some(value) => Value::Text(value.to_string()),
503 None => Value::Null,
504 };
505 let conn = self.inner.connect()?;
506 block_on(async {
507 conn.execute("BEGIN IMMEDIATE", ())
508 .await
509 .map_err(map_turso_err)?;
510 let result = match &self.uuid {
511 Some(uuid) => {
512 let updated = conn
513 .execute(
514 "UPDATE credentials SET comment = ?1 WHERE uuid = ?2",
515 (make_comment_value(), uuid.as_str()),
516 )
517 .await
518 .map_err(map_turso_err)?;
519 if updated == 0 {
520 Err(Error::NoEntry)
521 } else {
522 Ok(())
523 }
524 }
525 None if self.inner.allow_ambiguity => {
526 let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
527 match uuids.len() {
528 0 => Err(Error::NoEntry),
529 1 => {
530 conn.execute(
531 "UPDATE credentials SET comment = ?1 WHERE uuid = ?2",
532 (make_comment_value(), uuids[0].as_str()),
533 )
534 .await
535 .map_err(map_turso_err)?;
536 Ok(())
537 }
538 _ => Err(Error::Ambiguous(ambiguous_entries(
539 Arc::clone(&self.inner),
540 &self.id,
541 uuids,
542 ))),
543 }
544 }
545 None => {
546 let updated = conn
547 .execute(
548 "UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
549 (
550 make_comment_value(),
551 self.id.service.as_str(),
552 self.id.user.as_str(),
553 ),
554 )
555 .await
556 .map_err(map_turso_err)?;
557 if updated == 0 {
558 Err(Error::NoEntry)
559 } else {
560 Ok(())
561 }
562 }
563 };
564 match result {
565 Ok(()) => {
566 conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
567 Ok(())
568 }
569 Err(err) => {
570 let _ = conn.execute("ROLLBACK", ()).await;
571 Err(err)
572 }
573 }
574 })
575 }
576
577 fn delete_credential(&self) -> Result<()> {
578 validate_service_user(&self.id.service, &self.id.user)?;
579 let conn = self.inner.connect()?;
580 match &self.uuid {
581 Some(uuid) => {
582 let deleted = map_turso(block_on(
583 conn.execute("DELETE FROM credentials WHERE uuid = ?1", (uuid.as_str(),)),
584 ))?;
585 if deleted == 0 {
586 Err(Error::NoEntry)
587 } else {
588 Ok(())
589 }
590 }
591 None => {
592 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
593 match uuids.len() {
594 0 => Ok(()),
595 1 => {
596 map_turso(block_on(conn.execute(
597 "DELETE FROM credentials WHERE uuid = ?1",
598 (uuids[0].as_str(),),
599 )))?;
600 Ok(())
601 }
602 _ => Err(Error::Ambiguous(ambiguous_entries(
603 Arc::clone(&self.inner),
604 &self.id,
605 uuids,
606 ))),
607 }
608 }
609 }
610 }
611
612 fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
613 validate_service_user(&self.id.service, &self.id.user)?;
614 if self.uuid.is_some() {
615 return Ok(None);
616 }
617 let conn = self.inner.connect()?;
618 let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
619 match uuids.len() {
620 0 => Err(Error::NoEntry),
621 1 => Ok(Some(Arc::new(DbKeyCredential {
622 inner: Arc::clone(&self.inner),
623 id: self.id.clone(),
624 uuid: Some(uuids[0].clone()),
625 }))),
626 _ => Err(Error::Ambiguous(ambiguous_entries(
627 Arc::clone(&self.inner),
628 &self.id,
629 uuids,
630 ))),
631 }
632 }
633
634 fn get_specifiers(&self) -> Option<(String, String)> {
635 Some((self.id.service.clone(), self.id.user.clone()))
636 }
637
638 fn as_any(&self) -> &dyn std::any::Any {
639 self
640 }
641
642 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
643 fmt::Debug::fmt(self, f)
644 }
645}
646
647fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
648 map_turso(block_on(conn.execute(
649 "CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret TEXT NOT NULL, comment TEXT)",
650 (),
651 )))?;
652 if !allow_ambiguity {
653 map_turso(block_on(conn.execute(
655 "CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
656 (),
657 )))?;
658 } else if index_always {
659 map_turso(block_on(conn.execute(
664 "CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
665 (),
666 )))?;
667 }
668 Ok(())
669}
670
671async fn query_all_credentials(
672 conn: &Connection,
673) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
674 let mut rows = conn
675 .query("SELECT service, user, uuid, comment FROM credentials", ())
676 .await?;
677 let mut results = Vec::new();
678 while let Some(row) = rows.next().await? {
679 let service = value_to_string(row.get_value(0)?, "service")?;
680 let user = value_to_string(row.get_value(1)?, "user")?;
681 let uuid = value_to_string(row.get_value(2)?, "uuid")?;
682 let comment = value_to_option_string(row.get_value(3)?, "comment")?;
683 results.push((CredId { service, user }, uuid, comment));
684 }
685 Ok(results)
686}
687
688async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
689 let mut rows = conn
690 .query(
691 "SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
692 (id.service.as_str(), id.user.as_str()),
693 )
694 .await?;
695 let mut uuids = Vec::new();
696 while let Some(row) = rows.next().await? {
697 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
698 uuids.push(uuid);
699 }
700 Ok(uuids)
701}
702
703async fn fetch_secret_by_uuid(conn: &Connection, uuid: &str) -> turso::Result<Option<String>> {
704 let mut rows = conn
705 .query("SELECT secret FROM credentials WHERE uuid = ?1", (uuid,))
706 .await?;
707 let Some(row) = rows.next().await? else {
708 return Ok(None);
709 };
710 let secret = value_to_string(row.get_value(0)?, "secret")?;
711 Ok(Some(secret))
712}
713
714async fn fetch_secrets_by_id(
715 conn: &Connection,
716 id: &CredId,
717) -> turso::Result<Vec<(String, String)>> {
718 let mut rows = conn
719 .query(
720 "SELECT uuid, secret FROM credentials WHERE service = ?1 AND user = ?2",
721 (id.service.as_str(), id.user.as_str()),
722 )
723 .await?;
724 let mut results = Vec::new();
725 while let Some(row) = rows.next().await? {
726 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
727 let secret = value_to_string(row.get_value(1)?, "secret")?;
728 results.push((uuid, secret));
729 }
730 Ok(results)
731}
732
733async fn fetch_comment_by_uuid(
734 conn: &Connection,
735 uuid: &str,
736) -> turso::Result<Option<Option<String>>> {
737 let mut rows = conn
738 .query("SELECT comment FROM credentials WHERE uuid = ?1", (uuid,))
739 .await?;
740 if let Some(row) = rows.next().await? {
741 let comment = value_to_option_string(row.get_value(0)?, "comment")?;
742 Ok(Some(comment))
743 } else {
744 Ok(None)
745 }
746}
747
748async fn fetch_comments_by_id(
749 conn: &Connection,
750 id: &CredId,
751) -> turso::Result<Vec<(String, Option<String>)>> {
752 let mut rows = conn
753 .query(
754 "SELECT uuid, comment FROM credentials WHERE service = ?1 AND user = ?2",
755 (id.service.as_str(), id.user.as_str()),
756 )
757 .await?;
758 let mut results = Vec::new();
759 while let Some(row) = rows.next().await? {
760 let uuid = value_to_string(row.get_value(0)?, "uuid")?;
761 let comment = value_to_option_string(row.get_value(1)?, "comment")?;
762 results.push((uuid, comment));
763 }
764 Ok(results)
765}
766
767fn ambiguous_entries(inner: Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
768 uuids
769 .into_iter()
770 .map(|uuid| {
771 Entry::new_with_credential(Arc::new(DbKeyCredential {
772 inner: Arc::clone(&inner),
773 id: id.clone(),
774 uuid: Some(uuid),
775 }))
776 })
777 .collect()
778}
779
780fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
781 let mut attrs = HashMap::new();
782 attrs.insert("uuid".to_string(), uuid.to_string());
783 if let Some(comment) = comment {
784 attrs.insert("comment".to_string(), comment);
785 }
786 attrs
787}
788
789fn configure_connection(conn: &Connection) -> Result<()> {
790 map_turso(block_on(async {
791 let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
792 let _ = rows.next().await?;
793 let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
794 conn.execute(busy_stmt.as_str(), ()).await?;
795 Ok(())
796 }))
797}
798
799fn open_db_with_retry(
801 path_str: &str,
802 encryption_opts: Option<EncryptionOpts>,
803 vfs: Option<String>,
804) -> Result<Database> {
805 let mut retries = OPEN_LOCK_RETRIES;
806 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
807 loop {
808 let mut builder = Builder::new_local(path_str);
809 if let Some(opts) = encryption_opts.clone() {
810 let turso_enc_opts = turso::EncryptionOpts {
811 cipher: opts.cipher,
812 hexkey: opts.hexkey,
813 };
814 builder = builder
815 .experimental_encryption(true)
816 .with_encryption(turso_enc_opts);
817 }
818 if let Some(vfs) = vfs.clone() {
819 builder = builder.with_io(vfs);
820 }
821 match block_on(builder.build()) {
822 Ok(db) => return Ok(db),
823 Err(err) => {
824 if retries == 0 || !is_turso_locking_error(&err) {
825 return Err(map_turso_err(err));
826 }
827 retries -= 1;
828 let nanos = SystemTime::now()
829 .duration_since(UNIX_EPOCH)
830 .unwrap_or_default()
831 .subsec_nanos();
832 let jitter = (nanos % 20) as u64;
833 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
834 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
835 }
836 }
837 }
838}
839
840fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
841 let mut retries = OPEN_LOCK_RETRIES;
842 let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
843 loop {
844 match op() {
845 Ok(value) => return Ok(value),
846 Err(err) => {
847 if retries == 0 || !is_turso_locking_error(&err) {
848 return Err(map_turso_err(err));
849 }
850 retries -= 1;
851 let nanos = SystemTime::now()
852 .duration_since(UNIX_EPOCH)
853 .unwrap_or_default()
854 .subsec_nanos();
855 let jitter = (nanos % 20) as u64;
856 std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
857 backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
858 }
859 }
860 }
861}
862
863fn is_turso_locking_error(err: &turso::Error) -> bool {
864 let text = err.to_string().to_lowercase();
865 text.contains("locking error")
866 || text.contains("file is locked")
867 || text.contains("database is locked")
868 || text.contains("database is busy")
869 || text.contains("sqlite_busy")
870 || text.contains("sqlite_locked")
871}
872
873fn parse_bool_modifier(key: &str, value: &str) -> Result<bool> {
874 match value {
875 "true" => Ok(true),
876 "false" => Ok(false),
877 _ => Err(Error::Invalid(
878 key.to_string(),
879 "must be `true` or `false`".to_string(),
880 )),
881 }
882}
883
884fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
885 match value {
886 Value::Text(text) => Ok(text),
887 Value::Blob(blob) => String::from_utf8(blob)
888 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
889 other => Err(turso::Error::ConversionFailure(format!(
890 "unexpected value for {field}: {other:?}"
891 ))),
892 }
893}
894
895fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
896 match value {
897 Value::Null => Ok(None),
898 Value::Text(text) => Ok(Some(text)),
899 Value::Blob(blob) => String::from_utf8(blob)
900 .map(Some)
901 .map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
902 other => Err(turso::Error::ConversionFailure(format!(
903 "unexpected value for {field}: {other:?}"
904 ))),
905 }
906}
907
908fn ensure_parent_dir(path: &Path) -> Result<()> {
909 let parent = path
910 .parent()
911 .ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
912 if parent.as_os_str().is_empty() {
913 return Ok(());
914 }
915 std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
916}
917
918fn validate_service_user(service: &str, user: &str) -> Result<()> {
920 if service.is_empty() {
921 return Err(Error::Invalid(
922 "service".to_string(),
923 "service is empty".to_string(),
924 ));
925 }
926 if user.is_empty() {
927 return Err(Error::Invalid(
928 "user".to_string(),
929 "user is empty".to_string(),
930 ));
931 }
932 if service.len() > MAX_NAME_LEN {
933 return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN as u32));
934 }
935 if user.len() > MAX_NAME_LEN {
936 return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN as u32));
937 }
938 Ok(())
939}
940
941fn validate_secret(secret: &[u8]) -> Result<String> {
943 if secret.len() > MAX_SECRET_LEN {
944 return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN as u32));
945 }
946 let text = std::str::from_utf8(secret)
947 .map_err(|_| Error::Invalid("secret".to_string(), "secret is not utf8".to_string()))?;
948 Ok(text.to_string())
949}
950
951fn validate_search_spec(spec: &HashMap<&str, &str>) -> Result<()> {
952 for key in spec.keys() {
953 if *key != "service" && *key != "user" && *key != "uuid" && *key != "comment" {
954 return Err(Error::Invalid(
955 "spec".to_string(),
956 format!("unsupported key: {key}"),
957 ));
958 }
959 }
960 Ok(())
961}
962
963fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
964 result.map_err(map_turso_err)
965}
966
967fn map_turso_err(err: turso::Error) -> Error {
968 Error::PlatformFailure(Box::new(err))
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974
975 fn new_store(path: &Path) -> DbKeyStore {
976 let config = DbKeyStoreConfig {
977 path: path.to_path_buf(),
978 ..Default::default()
979 };
980 DbKeyStore::new(&config).expect("failed to create store")
981 }
982
983 fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
984 store
985 .build(service, user, None)
986 .expect("failed to build entry")
987 }
988
989 #[test]
990 fn set_password_then_search_finds_password() {
991 let dir = tempfile::tempdir().expect("tempdir");
992 let path = dir.path().join("store.db");
993 let store = new_store(&path);
994 let entry = build_entry(&store, "service", "user");
995 entry.set_password("hunter2").expect("set_password");
996
997 let mut spec = HashMap::new();
998 spec.insert("service", "service");
999 spec.insert("user", "user");
1000 let results = store.search(&spec).expect("search");
1001 assert_eq!(results.len(), 1);
1002 assert_eq!(results[0].get_password().unwrap(), "hunter2");
1003 }
1004
1005 #[test]
1006 fn comment_attributes_round_trip() {
1007 let dir = tempfile::tempdir().expect("tempdir");
1008 let path = dir.path().join("store.db");
1009 let store = new_store(&path);
1010 let entry = build_entry(&store, "service", "user");
1011 entry.set_password("hunter2").expect("set_password");
1012
1013 let update = HashMap::from([("comment", "note")]);
1014 entry.update_attributes(&update).expect("update_attributes");
1015 let attrs = entry.get_attributes().expect("get_attributes");
1016 assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
1017 assert!(attrs.contains_key("uuid"));
1018
1019 let mut spec = HashMap::new();
1020 spec.insert("service", "service");
1021 spec.insert("user", "user");
1022 spec.insert("comment", "note");
1023 let results = store.search(&spec).expect("search");
1024 assert_eq!(results.len(), 1);
1025
1026 let uuid = attrs.get("uuid").cloned().unwrap();
1027 let mut spec = HashMap::new();
1028 spec.insert("service", "service");
1029 spec.insert("user", "user");
1030 spec.insert("uuid", uuid.as_str());
1031 let results = store.search(&spec).expect("search");
1032 assert_eq!(results.len(), 1);
1033 }
1034
1035 #[test]
1036 fn stores_separate_service_user_pairs() {
1037 let dir = tempfile::tempdir().expect("tempdir");
1038 let path = dir.path().join("store.db");
1039 let store = new_store(&path);
1040
1041 build_entry(&store, "service1", "user1")
1042 .set_password("pw1")
1043 .unwrap();
1044 build_entry(&store, "service1", "user2")
1045 .set_password("pw2")
1046 .unwrap();
1047 build_entry(&store, "service", "user3")
1048 .set_password("pw3")
1049 .unwrap();
1050
1051 let mut spec = HashMap::new();
1052 spec.insert("service", "service1");
1053 spec.insert("user", "user1");
1054 let results = store.search(&spec).unwrap();
1055 assert_eq!(results.len(), 1);
1056 assert_eq!(results[0].get_password().unwrap(), "pw1");
1057
1058 spec.insert("user", "user2");
1059 let results = store.search(&spec).unwrap();
1060 assert_eq!(results.len(), 1);
1061 assert_eq!(results[0].get_password().unwrap(), "pw2");
1062
1063 spec.insert("service", "service");
1064 spec.insert("user", "user3");
1065 let results = store.search(&spec).unwrap();
1066 assert_eq!(results.len(), 1);
1067 assert_eq!(results[0].get_password().unwrap(), "pw3");
1068 }
1069
1070 #[test]
1071 fn search_missing_file_returns_empty() {
1072 let dir = tempfile::tempdir().expect("tempdir");
1073 let path = dir.path().join("store.db");
1074 let store = new_store(&path);
1075
1076 let mut spec = HashMap::new();
1077 spec.insert("service", "service");
1078 spec.insert("user", "user");
1079 let results = store.search(&spec).unwrap();
1080 assert!(results.is_empty());
1081 }
1082
1083 #[test]
1084 fn repeated_set_replaces_secret() {
1085 let dir = tempfile::tempdir().expect("tempdir");
1086 let path = dir.path().join("store.db");
1087 let store = new_store(&path);
1088 let entry = build_entry(&store, "service", "user");
1089 entry.set_password("first").unwrap();
1090 entry.set_secret(b"second").unwrap();
1091
1092 let mut spec = HashMap::new();
1093 spec.insert("service", "service");
1094 spec.insert("user", "user");
1095 let results = store.search(&spec).unwrap();
1096 assert_eq!(results.len(), 1);
1097 assert_eq!(results[0].get_password().unwrap(), "second");
1098 }
1099
1100 #[test]
1101 fn remove_is_idempotent() {
1102 let dir = tempfile::tempdir().expect("tempdir");
1103 let path = dir.path().join("store.db");
1104 let store = new_store(&path);
1105 let entry = build_entry(&store, "service", "user");
1106 entry.set_password("pw").unwrap();
1107 entry.delete_credential().unwrap();
1108 entry.delete_credential().unwrap();
1109 }
1110
1111 #[test]
1112 fn remove_clears_secret() {
1113 let dir = tempfile::tempdir().expect("tempdir");
1114 let path = dir.path().join("store.db");
1115 let store = new_store(&path);
1116 let entry = build_entry(&store, "service", "user");
1117 entry.set_password("pw").unwrap();
1118 entry.delete_credential().unwrap();
1119
1120 let mut spec = HashMap::new();
1121 spec.insert("service", "service");
1122 spec.insert("user", "user");
1123 let results = store.search(&spec).unwrap();
1124 assert!(results.is_empty());
1125 }
1126}