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