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