#![warn(clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
use std::{
collections::HashMap,
fmt,
path::{Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use futures::executor::block_on;
use keyring_core::{
api::{CredentialApi, CredentialPersistence, CredentialStoreApi},
attributes::parse_attributes,
{Credential, Entry, Error, Result},
};
use regex::Regex;
use turso::{Builder, Connection, Database, Value};
use zeroize::Zeroizing;
const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
const MAX_NAME_LEN: u32 = 1024;
const MAX_SECRET_LEN: u32 = 65536;
const SCHEMA_VERSION: u32 = 1;
const BUSY_TIMEOUT_MS: u32 = 5000;
const OPEN_LOCK_RETRIES: u32 = 60;
const OPEN_LOCK_BACKOFF_MS: u64 = 20;
const OPEN_LOCK_BACKOFF_MAX_MS: u64 = 250;
#[derive(Debug, Default, Clone)]
pub struct EncryptionOpts {
pub cipher: String,
pub hexkey: String,
}
impl EncryptionOpts {
pub fn new(cipher: impl Into<String>, hexkey: impl Into<String>) -> Self {
Self {
cipher: cipher.into(),
hexkey: hexkey.into(),
}
}
}
struct EncryptionOptsZero {
cipher: String,
hexkey: Zeroizing<String>,
}
impl From<EncryptionOpts> for EncryptionOptsZero {
fn from(value: EncryptionOpts) -> Self {
Self {
cipher: value.cipher,
hexkey: Zeroizing::new(value.hexkey),
}
}
}
fn new_uuid() -> String {
uuid::Uuid::now_v7().to_string()
}
#[derive(Debug, Default, Clone)]
pub struct DbKeyStoreConfig {
pub path: PathBuf,
pub encryption_opts: Option<EncryptionOpts>,
pub allow_ambiguity: bool,
pub vfs: Option<String>,
pub index_always: bool,
}
pub fn default_path() -> Result<PathBuf> {
Ok(match std::env::var("XDG_STATE_HOME") {
Ok(dir) => PathBuf::from(dir),
_ => match std::env::var("HOME") {
Ok(home) => PathBuf::from(home).join(".local").join("state"),
_ => {
return Err(Error::Invalid(
"path".to_owned(),
"No default path: set 'path' in Config (or modifiers), or define XDG_STATE_HOME or HOME"
.to_owned(),
));
}
},
}
.join("keystore.db"))
}
#[derive(Clone)]
pub struct DbKeyStore {
inner: Arc<DbKeyStoreInner>,
}
#[derive(Debug)]
struct DbKeyStoreInner {
db: Database,
id: String,
allow_ambiguity: bool,
encrypted: bool,
path: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
struct CredId {
service: String,
user: String,
}
#[derive(Debug, Clone)]
struct DbKeyCredential {
inner: Arc<DbKeyStoreInner>,
id: CredId,
uuid: Option<String>,
comment: Option<String>,
}
#[derive(Debug)]
enum LookupResult<T> {
None,
One(T),
Ambiguous(Vec<String>),
}
#[derive(Debug)]
struct CommentRow {
uuid: String,
comment: Option<String>,
}
impl DbKeyStore {
pub fn new(config: DbKeyStoreConfig) -> Result<Arc<DbKeyStore>> {
let start_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let zero_opts = config.encryption_opts.map(EncryptionOptsZero::from);
let (store, conn) = if let Some(vfs) = &config.vfs
&& vfs == "memory"
{
let db = map_turso(block_on(async {
Builder::new_local(":memory:")
.with_io("memory".into())
.build()
.await
}))?;
let id = format!("DbKeyStore v{CRATE_VERSION} in-memory @ {start_time}",);
let conn = map_turso(db.connect())?;
(
DbKeyStore {
inner: Arc::new(DbKeyStoreInner {
db,
id,
allow_ambiguity: config.allow_ambiguity,
encrypted: false,
path: ":memory:".to_string(),
}),
},
conn,
)
} else {
let path = if config.path.as_os_str().is_empty() {
default_path()?
} else {
config.path.clone()
};
let path_str = path.to_str().ok_or_else(|| {
Error::Invalid("path".into(), "path must be valid UTF-8".to_string())
})?;
ensure_parent_dir(&path)?;
let encrypted = zero_opts.as_ref().is_some_and(|o| !o.cipher.is_empty());
let db = open_db_with_retry(path_str, zero_opts.as_ref(), config.vfs.as_deref())?;
let conn = retry_turso_locking(|| db.connect())?;
configure_connection(&conn)?;
let id = format!(
"DbKeyStore v{CRATE_VERSION} path:{path_str} enc:{encrypted} @ {start_time}",
);
(
DbKeyStore {
inner: Arc::new(DbKeyStoreInner {
db,
id,
allow_ambiguity: config.allow_ambiguity,
encrypted,
path: path_str.to_string(),
}),
},
conn,
)
};
init_schema(&conn, config.allow_ambiguity, config.index_always)?;
Ok(Arc::new(store))
}
pub fn new_with_modifiers(modifiers: &HashMap<&str, &str>) -> Result<Arc<DbKeyStore>> {
let mut mods = parse_attributes(
&[
"path",
"encryption-cipher",
"cipher",
"encryption-hexkey",
"hexkey",
"*allow-ambiguity",
"*allow_ambiguity",
"vfs",
"*index-always",
"*index_always",
],
Some(modifiers),
)?;
let path = mods.remove("path").map(PathBuf::from).unwrap_or_default();
let cipher = mods
.remove("encryption-cipher")
.or_else(|| mods.remove("cipher"));
let hexkey = mods
.remove("encryption-hexkey")
.or_else(|| mods.remove("hexkey"));
let allow_ambiguity = mods
.remove("allow-ambiguity")
.or_else(|| mods.remove("allow_ambiguity"))
.is_some_and(|value| value == "true");
let index_always = mods
.remove("index-always")
.or_else(|| mods.remove("index_always"))
.is_some_and(|value| value == "true");
let vfs = mods.remove("vfs");
let encryption_opts = match (cipher, hexkey) {
(None, None) => None,
(Some(cipher), Some(hexkey)) => Some(EncryptionOpts::new(cipher, hexkey)),
_ => {
return Err(Error::Invalid(
"encryption".to_string(),
"encryption-cipher and encryption-hexkey must both be set".to_string(),
));
}
};
let config = DbKeyStoreConfig {
path,
encryption_opts,
allow_ambiguity,
vfs,
index_always,
};
DbKeyStore::new(config)
}
pub fn is_encrypted(&self) -> bool {
self.inner.encrypted
}
pub fn path(&self) -> String {
self.inner.path.clone()
}
}
impl std::fmt::Debug for DbKeyStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DbKeyStore")
.field("vendor", &self.vendor())
.field("id", &self.id())
.field("allow_ambiguity", &self.inner.allow_ambiguity)
.finish()
}
}
impl DbKeyStoreInner {
fn connect(&self) -> Result<Connection> {
let conn = map_turso(self.db.connect())?;
configure_connection(&conn)?;
Ok(conn)
}
}
impl DbKeyCredential {
async fn insert_credential(
&self,
conn: &Connection,
uuid: &str,
secret: Value,
comment: Value,
) -> Result<()> {
conn.execute(
"INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
(
self.id.service.as_str(),
self.id.user.as_str(),
uuid,
secret,
comment,
),
)
.await
.map_err(map_turso_err)?;
Ok(())
}
}
impl CredentialStoreApi for DbKeyStore {
fn vendor(&self) -> String {
String::from("DbKeyStore, https://crates.io/crates/db-keystore")
}
fn id(&self) -> String {
self.inner.id.clone()
}
fn build(
&self,
service: &str,
user: &str,
modifiers: Option<&HashMap<&str, &str>>,
) -> Result<Entry> {
validate_service_user(service, user)?;
let mods = parse_attributes(&["uuid", "comment"], modifiers)?;
let credential = DbKeyCredential {
inner: Arc::clone(&self.inner),
id: CredId {
service: service.to_string(),
user: user.to_string(),
},
uuid: mods
.get("uuid")
.map(|value| normalize_uuid_input(value))
.transpose()?,
comment: mods.get("comment").cloned(),
};
Ok(Entry::new_with_credential(Arc::new(credential)))
}
fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
let spec = parse_attributes(&["service", "user", "uuid", "comment"], Some(spec))?;
let service_re = Regex::new(spec.get("service").map_or("", String::as_str))
.map_err(|e| Error::Invalid("service regex".to_string(), e.to_string()))?;
let user_re = Regex::new(spec.get("user").map_or("", String::as_str))
.map_err(|e| Error::Invalid("user regex".to_string(), e.to_string()))?;
let comment_re = Regex::new(spec.get("comment").map_or("", String::as_str))
.map_err(|e| Error::Invalid("comment regex".to_string(), e.to_string()))?;
let uuid_spec = match spec.get("uuid") {
Some(value) => Some(normalize_uuid_input(value)?),
None => None,
};
let uuid_re = Regex::new(uuid_spec.as_deref().unwrap_or(""))
.map_err(|e| Error::Invalid("uuid regex".to_string(), e.to_string()))?;
let conn = self.inner.connect()?;
let rows = map_turso(block_on(query_all_credentials(&conn)))?;
let mut entries = Vec::new();
let comment_filter = spec.get("comment").cloned();
let filter_comment = spec.contains_key("comment");
let filter_comment_empty = comment_filter.as_deref().is_some_and(str::is_empty);
for (id, uuid, comment) in rows {
if !service_re.is_match(id.service.as_str()) {
continue;
}
if !user_re.is_match(id.user.as_str()) {
continue;
}
if !uuid_re.is_match(uuid.as_str()) {
continue;
}
if filter_comment {
if filter_comment_empty {
if comment.as_deref().is_some_and(|value| !value.is_empty()) {
continue;
}
} else {
match comment.as_ref() {
Some(text) if comment_re.is_match(text.as_str()) => {}
_ => continue,
}
}
}
let credential = DbKeyCredential {
inner: Arc::clone(&self.inner),
id,
uuid: Some(uuid),
comment: None,
};
entries.push(Entry::new_with_credential(Arc::new(credential)));
}
Ok(entries)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn persistence(&self) -> CredentialPersistence {
CredentialPersistence::UntilDelete
}
fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
impl DbKeyCredential {
fn get_secret_zeroizing(&self) -> Result<Zeroizing<Vec<u8>>> {
validate_service_user(&self.id.service, &self.id.user)?;
let conn = self.inner.connect()?;
if let Some(uuid) = &self.uuid {
let match_result = map_turso(block_on(fetch_secret_by_key(&conn, &self.id, uuid)))?;
match match_result {
LookupResult::None => Err(Error::NoEntry),
LookupResult::One(secret) => Ok(secret),
LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
&Arc::clone(&self.inner),
&self.id,
uuids,
))),
}
} else {
let match_result = map_turso(block_on(fetch_secret_by_id(&conn, &self.id)))?;
match match_result {
LookupResult::None => Err(Error::NoEntry),
LookupResult::One(secret) => Ok(secret),
LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
&Arc::clone(&self.inner),
&self.id,
uuids,
))),
}
}
}
async fn set_secret_unambiguous(
&self,
conn: &Connection,
make_secret_value: &dyn Fn() -> Value,
make_comment_value: &dyn Fn() -> Value,
) -> Result<()> {
let uuid = new_uuid();
let _ = conn.execute(
"INSERT INTO credentials (service, user, uuid, secret, comment) VALUES (?1, ?2, ?3, ?4, ?5) \
ON CONFLICT(service, user) DO UPDATE SET secret = excluded.secret",
(
self.id.service.as_str(),
self.id.user.as_str(),
uuid.as_str(),
make_secret_value(),
make_comment_value(),
),
)
.await.map_err(map_turso_err)?;
Ok(())
}
async fn set_secret_with_uuid(
&self,
conn: &Connection,
uuid: &str,
make_secret_value: &dyn Fn() -> Value,
make_comment_value: &dyn Fn() -> Value,
) -> Result<()> {
let updated = conn
.execute(
"UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
(
make_secret_value(),
self.id.service.as_str(),
self.id.user.as_str(),
uuid,
),
)
.await
.map_err(map_turso_err)?;
if updated > 0 {
return Ok(());
}
if !self.inner.allow_ambiguity {
let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
match uuids.len() {
0 => {}
1 => {
if uuids[0] != uuid {
return Err(Error::Invalid(
"uuid".to_string(),
"can't create ambiguous credential for service/user".to_string(),
));
}
}
_ => {
return Err(Error::PlatformFailure(format!(
"Database is in an invalid state: ambiguity not allowed, but multiple entries found for {:?}",
&self.id
).into()));
}
}
}
self.insert_credential(conn, uuid, make_secret_value(), make_comment_value())
.await?;
Ok(())
}
async fn set_secret_without_uuid(
&self,
conn: &Connection,
make_secret_value: &dyn Fn() -> Value,
make_comment_value: &dyn Fn() -> Value,
) -> Result<()> {
let uuids = fetch_uuids(conn, &self.id).await.map_err(map_turso_err)?;
match uuids.len() {
0 => {
let uuid = new_uuid();
self.insert_credential(
conn,
uuid.as_str(),
make_secret_value(),
make_comment_value(),
)
.await?;
Ok(())
}
1 => {
conn.execute(
"UPDATE credentials SET secret = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
(
make_secret_value(),
self.id.service.as_str(),
self.id.user.as_str(),
uuids[0].as_str(),
),
)
.await
.map_err(map_turso_err)?;
Ok(())
}
_ => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
}
async fn set_secret_in_tx(
&self,
conn: &Connection,
make_secret_value: &dyn Fn() -> Value,
make_comment_value: &dyn Fn() -> Value,
) -> Result<()> {
if let Some(uuid) = &self.uuid {
self.set_secret_with_uuid(conn, uuid.as_str(), make_secret_value, make_comment_value)
.await
} else {
self.set_secret_without_uuid(conn, make_secret_value, make_comment_value)
.await
}
}
async fn finish_tx(conn: &Connection, result: Result<()>) -> Result<()> {
match result {
Ok(()) => {
conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
Ok(())
}
Err(err) => {
if let Err(e2) = conn.execute("ROLLBACK", ()).await {
log::error!(
"While handling set_secret error ({err:?}). attempted ROLLBACK, which encountered secondary error: {e2:?}"
);
}
Err(err)
}
}
}
}
impl CredentialApi for DbKeyCredential {
fn set_secret(&self, secret: &[u8]) -> Result<()> {
validate_service_user(&self.id.service, &self.id.user)?;
validate_secret(secret)?;
let make_secret_value = || Value::Blob(secret.to_vec());
let make_comment_value = || comment_value(self.comment.as_ref());
let conn = self.inner.connect()?;
if self.uuid.is_none() && !self.inner.allow_ambiguity {
return block_on(self.set_secret_unambiguous(
&conn,
&make_secret_value,
&make_comment_value,
));
}
block_on(async {
conn.execute("BEGIN IMMEDIATE", ())
.await
.map_err(map_turso_err)?;
let result = self
.set_secret_in_tx(&conn, &make_secret_value, &make_comment_value)
.await;
Self::finish_tx(&conn, result).await
})
}
fn get_secret(&self) -> Result<Vec<u8>> {
let secret = self.get_secret_zeroizing()?;
Ok(take_zeroizing_vec(secret))
}
fn get_attributes(&self) -> Result<HashMap<String, String>> {
validate_service_user(&self.id.service, &self.id.user)?;
let conn = self.inner.connect()?;
if let Some(uuid) = &self.uuid {
let match_result = map_turso(block_on(fetch_comment_by_key(&conn, &self.id, uuid)))?;
match match_result {
LookupResult::None => Err(Error::NoEntry),
LookupResult::One(comment) => Ok(attributes_for_uuid(uuid.as_str(), comment)),
LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
} else {
let match_result = map_turso(block_on(fetch_comment_by_id(&conn, &self.id)))?;
match match_result {
LookupResult::None => Err(Error::NoEntry),
LookupResult::One(row) => Ok(attributes_for_uuid(row.uuid.as_str(), row.comment)),
LookupResult::Ambiguous(uuids) => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
}
}
fn update_attributes(&self, attrs: &HashMap<&str, &str>) -> Result<()> {
parse_attributes(&["comment"], Some(attrs))?;
let comment = attrs.get("comment").map(ToString::to_string);
let has_comment = attrs.contains_key("comment");
if !has_comment {
self.get_attributes()?;
return Ok(());
}
let comment = comment.and_then(|value| if value.is_empty() { None } else { Some(value) });
let make_comment_value = || comment_value(comment.as_ref());
let conn = self.inner.connect()?;
block_on(async {
conn.execute("BEGIN IMMEDIATE", ())
.await
.map_err(map_turso_err)?;
let result = match &self.uuid {
Some(uuid) => {
let uuids = fetch_uuids_by_key(&conn, &self.id, uuid)
.await
.map_err(map_turso_err)?;
match uuids.len() {
0 => Err(Error::NoEntry),
1 => {
conn.execute(
"UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
(
make_comment_value(),
self.id.service.as_str(),
self.id.user.as_str(),
uuid.as_str(),
),
)
.await
.map_err(map_turso_err)?;
Ok(())
}
_ => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
}
None if self.inner.allow_ambiguity => {
let uuids = fetch_uuids(&conn, &self.id).await.map_err(map_turso_err)?;
match uuids.len() {
0 => Err(Error::NoEntry),
1 => {
conn.execute(
"UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3 AND uuid = ?4",
(
make_comment_value(),
self.id.service.as_str(),
self.id.user.as_str(),
uuids[0].as_str(),
),
)
.await
.map_err(map_turso_err)?;
Ok(())
}
_ => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
}
None => {
let updated = conn
.execute(
"UPDATE credentials SET comment = ?1 WHERE service = ?2 AND user = ?3",
(
make_comment_value(),
self.id.service.as_str(),
self.id.user.as_str(),
),
)
.await
.map_err(map_turso_err)?;
if updated == 0 {
Err(Error::NoEntry)
} else {
Ok(())
}
}
};
match result {
Ok(()) => {
conn.execute("COMMIT", ()).await.map_err(map_turso_err)?;
Ok(())
}
Err(err) => {
let _ = conn.execute("ROLLBACK", ()).await;
Err(err)
}
}
})
}
fn delete_credential(&self) -> Result<()> {
validate_service_user(&self.id.service, &self.id.user)?;
let conn = self.inner.connect()?;
if let Some(uuid) = &self.uuid {
let deleted = map_turso(block_on(conn.execute(
"DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
(
self.id.service.as_str(),
self.id.user.as_str(),
uuid.as_str(),
),
)))?;
if deleted == 0 {
Err(Error::NoEntry)
} else {
Ok(())
}
} else {
let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
match uuids.len() {
0 => Err(Error::NoEntry),
1 => {
map_turso(block_on(conn.execute(
"DELETE FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
(
self.id.service.as_str(),
self.id.user.as_str(),
uuids[0].as_str(),
),
)))?;
Ok(())
}
_ => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
}
}
fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
validate_service_user(&self.id.service, &self.id.user)?;
let conn = self.inner.connect()?;
if let Some(uuid) = &self.uuid {
let uuids = map_turso(block_on(fetch_uuids_by_key(&conn, &self.id, uuid)))?;
match uuids.len() {
0 => Err(Error::NoEntry),
1 => Ok(Some(Arc::new(DbKeyCredential {
inner: Arc::clone(&self.inner),
id: self.id.clone(),
uuid: Some(uuid.clone()),
comment: None,
}))),
_ => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
} else {
let uuids = map_turso(block_on(fetch_uuids(&conn, &self.id)))?;
match uuids.len() {
0 => Err(Error::NoEntry),
1 => Ok(Some(Arc::new(DbKeyCredential {
inner: Arc::clone(&self.inner),
id: self.id.clone(),
uuid: Some(uuids[0].clone()),
comment: None,
}))),
_ => Err(Error::Ambiguous(ambiguous_entries(
&self.inner,
&self.id,
uuids,
))),
}
}
}
fn get_specifiers(&self) -> Option<(String, String)> {
Some((self.id.service.clone(), self.id.user.clone()))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
fn init_schema(conn: &Connection, allow_ambiguity: bool, index_always: bool) -> Result<()> {
map_turso(block_on(conn.execute(
"CREATE TABLE IF NOT EXISTS credentials (service TEXT NOT NULL, user TEXT NOT NULL, uuid TEXT NOT NULL, secret BLOB NOT NULL, comment TEXT)",
(),
)))?;
map_turso(block_on(conn.execute(
"CREATE TABLE IF NOT EXISTS keystore_meta (key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL)",
(),
)))?;
ensure_schema_version(conn)?;
if !allow_ambiguity {
map_turso(block_on(conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uidx_credentials_service_user ON credentials (service, user)",
(),
)))?;
} else if index_always {
map_turso(block_on(conn.execute(
"CREATE INDEX IF NOT EXISTS idx_credentials_service_user ON credentials (service, user)",
(),
)))?;
}
Ok(())
}
fn ensure_schema_version(conn: &Connection) -> Result<()> {
map_turso(block_on(async {
let mut rows = conn
.query(
"SELECT value FROM keystore_meta WHERE key = 'schema_version'",
(),
)
.await?;
if let Some(row) = rows.next().await? {
let value = value_to_string(row.get_value(0)?, "schema_version")?;
let version = value.parse::<u32>().map_err(|_| {
turso::Error::ConversionFailure(format!("invalid schema_version value: {value}"))
})?;
if version != SCHEMA_VERSION {
return Err(turso::Error::ConversionFailure(format!(
"unsupported schema version: {version}"
)));
}
} else {
conn.execute(
"INSERT INTO keystore_meta (key, value) VALUES ('schema_version', ?1)",
(SCHEMA_VERSION.to_string(),),
)
.await?;
}
Ok(())
}))
}
async fn query_all_credentials(
conn: &Connection,
) -> turso::Result<Vec<(CredId, String, Option<String>)>> {
let mut rows = conn
.query("SELECT service, user, uuid, comment FROM credentials", ())
.await?;
let mut results = Vec::new();
while let Some(row) = rows.next().await? {
let service = value_to_string(row.get_value(0)?, "service")?;
let user = value_to_string(row.get_value(1)?, "user")?;
let uuid = value_to_string(row.get_value(2)?, "uuid")?;
let comment = value_to_option_string(row.get_value(3)?, "comment")?;
results.push((CredId { service, user }, uuid, comment));
}
Ok(results)
}
async fn fetch_uuids(conn: &Connection, id: &CredId) -> turso::Result<Vec<String>> {
let mut rows = conn
.query(
"SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2",
(id.service.as_str(), id.user.as_str()),
)
.await?;
let mut uuids = Vec::new();
while let Some(row) = rows.next().await? {
let uuid = value_to_string(row.get_value(0)?, "uuid")?;
uuids.push(uuid);
}
Ok(uuids)
}
async fn fetch_secret_by_key(
conn: &Connection,
id: &CredId,
uuid: &str,
) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
let mut rows = conn
.query(
"SELECT secret FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
(id.service.as_str(), id.user.as_str(), uuid),
)
.await?;
let mut secrets = Vec::new();
while let Some(row) = rows.next().await? {
let secret = value_to_secret(row.get_value(0)?, "secret")?;
secrets.push(secret);
}
match secrets.len() {
0 => Ok(LookupResult::None),
1 => Ok(LookupResult::One(
secrets.into_iter().next().expect("secret for single match"),
)),
_ => Ok(LookupResult::Ambiguous(vec![
uuid.to_string();
secrets.len()
])),
}
}
async fn fetch_comment_by_key(
conn: &Connection,
id: &CredId,
uuid: &str,
) -> turso::Result<LookupResult<Option<String>>> {
let mut rows = conn
.query(
"SELECT comment FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
(id.service.as_str(), id.user.as_str(), uuid),
)
.await?;
let mut comments = Vec::new();
while let Some(row) = rows.next().await? {
let comment = value_to_option_string(row.get_value(0)?, "comment")?;
comments.push(comment);
}
match comments.len() {
0 => Ok(LookupResult::None),
1 => Ok(LookupResult::One(
comments
.into_iter()
.next()
.expect("comment for single match"),
)),
_ => Ok(LookupResult::Ambiguous(vec![
uuid.to_string();
comments.len()
])),
}
}
async fn fetch_secret_by_id(
conn: &Connection,
id: &CredId,
) -> turso::Result<LookupResult<Zeroizing<Vec<u8>>>> {
let uuids = fetch_uuids(conn, id).await?;
match uuids.len() {
0 => Ok(LookupResult::None),
1 => fetch_secret_by_key(conn, id, uuids[0].as_str()).await,
_ => Ok(LookupResult::Ambiguous(uuids)),
}
}
async fn fetch_comment_by_id(
conn: &Connection,
id: &CredId,
) -> turso::Result<LookupResult<CommentRow>> {
let uuids = fetch_uuids(conn, id).await?;
match uuids.len() {
0 => Ok(LookupResult::None),
1 => {
let uuid = uuids.into_iter().next().expect("uuid");
match fetch_comment_by_key(conn, id, uuid.as_str()).await? {
LookupResult::None => Ok(LookupResult::None),
LookupResult::One(comment) => Ok(LookupResult::One(CommentRow { uuid, comment })),
LookupResult::Ambiguous(uuids) => Ok(LookupResult::Ambiguous(uuids)),
}
}
_ => Ok(LookupResult::Ambiguous(uuids)),
}
}
async fn fetch_uuids_by_key(
conn: &Connection,
id: &CredId,
uuid: &str,
) -> turso::Result<Vec<String>> {
let mut rows = conn
.query(
"SELECT uuid FROM credentials WHERE service = ?1 AND user = ?2 AND uuid = ?3",
(id.service.as_str(), id.user.as_str(), uuid),
)
.await?;
let mut uuids = Vec::new();
while let Some(row) = rows.next().await? {
let uuid = value_to_string(row.get_value(0)?, "uuid")?;
uuids.push(uuid);
}
Ok(uuids)
}
fn ambiguous_entries(inner: &Arc<DbKeyStoreInner>, id: &CredId, uuids: Vec<String>) -> Vec<Entry> {
uuids
.into_iter()
.map(|uuid| {
Entry::new_with_credential(Arc::new(DbKeyCredential {
inner: Arc::clone(inner),
id: id.clone(),
uuid: Some(uuid),
comment: None,
}))
})
.collect()
}
fn attributes_for_uuid(uuid: &str, comment: Option<String>) -> HashMap<String, String> {
let mut attrs = HashMap::new();
attrs.insert("uuid".to_string(), uuid.to_string());
if let Some(comment) = comment {
attrs.insert("comment".to_string(), comment);
}
attrs
}
fn comment_value(comment: Option<&String>) -> Value {
match comment {
Some(value) if !value.is_empty() => Value::Text(value.clone()),
_ => Value::Null,
}
}
fn normalize_uuid_input(value: &str) -> Result<String> {
let lower = value.to_ascii_lowercase();
let uuid = uuid::Uuid::try_parse(&lower)
.map_err(|_| Error::Invalid("uuid".to_string(), "invalid uuid format".to_string()))?;
if uuid.to_string() != lower {
return Err(Error::Invalid(
"uuid".to_string(),
"invalid uuid format".to_string(),
));
}
Ok(lower)
}
fn take_zeroizing_vec(mut value: Zeroizing<Vec<u8>>) -> Vec<u8> {
std::mem::take(&mut *value)
}
fn configure_connection(conn: &Connection) -> Result<()> {
map_turso(block_on(async {
let mut rows = conn.query("PRAGMA journal_mode=WAL", ()).await?;
let _ = rows.next().await?;
let busy_stmt = format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}");
conn.execute(busy_stmt.as_str(), ()).await?;
Ok(())
}))
}
fn open_db_with_retry(
path_str: &str,
encryption_opts: Option<&EncryptionOptsZero>,
vfs: Option<&str>,
) -> Result<Database> {
let mut retries = OPEN_LOCK_RETRIES;
let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
loop {
let mut builder = Builder::new_local(path_str);
if let Some(opts) = &encryption_opts {
let turso_enc_opts = turso::EncryptionOpts {
cipher: opts.cipher.clone(),
hexkey: opts.hexkey.to_string(),
};
builder = builder
.experimental_encryption(true)
.with_encryption(turso_enc_opts);
}
if let Some(vfs) = vfs {
builder = builder.with_io(vfs.to_string());
}
match block_on(builder.build()) {
Ok(db) => return Ok(db),
Err(err) => {
check_decryption_error(&err)?;
if retries == 0 || !is_turso_locking_error(&err) {
return Err(map_turso_err(err));
}
retries -= 1;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let jitter = u64::from(nanos % 20);
std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
}
}
}
}
fn retry_turso_locking<T>(mut op: impl FnMut() -> turso::Result<T>) -> Result<T> {
let mut retries = OPEN_LOCK_RETRIES;
let mut backoff_ms = OPEN_LOCK_BACKOFF_MS;
loop {
match op() {
Ok(value) => return Ok(value),
Err(err) => {
if retries == 0 || !is_turso_locking_error(&err) {
return Err(map_turso_err(err));
}
retries -= 1;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let jitter = u64::from(nanos % 20);
std::thread::sleep(std::time::Duration::from_millis(backoff_ms + jitter));
backoff_ms = (backoff_ms * 2).min(OPEN_LOCK_BACKOFF_MAX_MS);
}
}
}
}
fn is_turso_locking_error(err: &turso::Error) -> bool {
let text = err.to_string().to_lowercase();
text.contains("locking error")
|| text.contains("file is locked")
|| text.contains("database is locked")
|| text.contains("database is busy")
|| text.contains("sqlite_busy")
|| text.contains("sqlite_locked")
}
fn check_decryption_error(err: &turso::Error) -> Result<()> {
let text = err.to_string();
if text.starts_with("Decryption failed") {
return Err(keyring_core::Error::NoStorageAccess(Box::new(
turso::Error::Error(format!("Invalid encryption key or cipher. {text}")),
)));
}
Ok(())
}
fn value_to_string(value: Value, field: &str) -> turso::Result<String> {
match value {
Value::Text(text) => Ok(text),
Value::Blob(blob) => String::from_utf8(blob)
.map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
other => Err(turso::Error::ConversionFailure(format!(
"unexpected value for {field}: {other:?}"
))),
}
}
fn value_to_secret(value: Value, field: &str) -> turso::Result<Zeroizing<Vec<u8>>> {
match value {
Value::Blob(blob) => Ok(Zeroizing::new(blob)),
Value::Text(text) => Ok(Zeroizing::new(text.into_bytes())),
other => Err(turso::Error::ConversionFailure(format!(
"unexpected value for {field}: {other:?}"
))),
}
}
fn value_to_option_string(value: Value, field: &str) -> turso::Result<Option<String>> {
match value {
Value::Null => Ok(None),
Value::Text(text) => Ok(Some(text)),
Value::Blob(blob) => String::from_utf8(blob)
.map(Some)
.map_err(|e| turso::Error::ConversionFailure(format!("invalid utf8 for {field}: {e}"))),
other => Err(turso::Error::ConversionFailure(format!(
"unexpected value for {field}: {other:?}"
))),
}
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| Error::Invalid("path".to_string(), "path has no parent".to_string()))?;
if parent.as_os_str().is_empty() {
return Ok(());
}
std::fs::create_dir_all(parent).map_err(|e| Error::PlatformFailure(Box::new(e)))
}
fn validate_service_user(service: &str, user: &str) -> Result<()> {
if service.is_empty() {
return Err(Error::Invalid(
"service".to_string(),
"service is empty".to_string(),
));
}
if user.is_empty() {
return Err(Error::Invalid(
"user".to_string(),
"user is empty".to_string(),
));
}
if service.len() > MAX_NAME_LEN as usize {
return Err(Error::TooLong("service".to_string(), MAX_NAME_LEN));
}
if user.len() > MAX_NAME_LEN as usize {
return Err(Error::TooLong("user".to_string(), MAX_NAME_LEN));
}
Ok(())
}
fn validate_secret(secret: &[u8]) -> Result<()> {
if secret.len() > MAX_SECRET_LEN as usize {
return Err(Error::TooLong("secret".to_string(), MAX_SECRET_LEN));
}
Ok(())
}
fn map_turso<T>(result: std::result::Result<T, turso::Error>) -> Result<T> {
result.map_err(map_turso_err)
}
fn map_turso_err(err: turso::Error) -> Error {
Error::PlatformFailure(Box::new(err))
}
#[cfg(test)]
mod tests {
use super::*;
fn new_store(path: &Path) -> Arc<DbKeyStore> {
let config = DbKeyStoreConfig {
path: path.to_path_buf(),
..Default::default()
};
DbKeyStore::new(config).expect("failed to create store")
}
fn build_entry(store: &DbKeyStore, service: &str, user: &str) -> Entry {
store
.build(service, user, None)
.expect("failed to build entry")
}
fn set_password(entry: &Entry, password: &str) -> Result<()> {
entry.set_password(password)
}
fn set_secret(entry: &Entry, secret: &[u8]) -> Result<()> {
entry.set_secret(secret)
}
fn get_password(entry: &Entry) -> Result<Zeroizing<String>> {
Ok(Zeroizing::new(entry.get_password()?))
}
#[test]
fn create_store_creates_parent_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("nested").join("deeply").join("keystore.db");
let parent = db_path.parent().expect("parent");
assert!(!parent.exists());
let config = DbKeyStoreConfig {
path: db_path.clone(),
..Default::default()
};
let store = DbKeyStore::new(config).expect("create store");
assert!(parent.is_dir());
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "dromomeryx").expect("set_password");
}
#[test]
fn set_password_then_search_finds_password() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "dromomeryx").expect("set_password");
let mut spec = HashMap::new();
spec.insert("service", "demo");
spec.insert("user", "alice");
let results = store.search(&spec).expect("search");
assert_eq!(results.len(), 1);
let password = get_password(&results[0]).expect("get_password");
assert_eq!(password.as_str(), "dromomeryx");
}
#[test]
fn comment_attributes_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "dromomeryx").expect("set_password");
let update = HashMap::from([("comment", "note")]);
entry.update_attributes(&update).expect("update_attributes");
let attrs = entry.get_attributes().expect("get_attributes");
assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
assert!(attrs.contains_key("uuid"));
let mut spec = HashMap::new();
spec.insert("service", "demo");
spec.insert("user", "alice");
spec.insert("comment", "note");
let results = store.search(&spec).expect("search");
assert_eq!(results.len(), 1);
let uuid = attrs.get("uuid").cloned().expect("get uuid");
let mut spec = HashMap::new();
spec.insert("service", "demo");
spec.insert("user", "alice");
spec.insert("uuid", uuid.as_str());
let results = store.search(&spec).expect("search");
assert_eq!(results.len(), 1);
}
#[test]
fn comment_with_password_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "dromomeryx").expect("set_password");
let update = HashMap::from([("comment", "note")]);
entry.update_attributes(&update).expect("update_attributes");
let mut spec = HashMap::new();
spec.insert("service", "demo");
spec.insert("user", "alice");
spec.insert("comment", "note");
let results = store.search(&spec).expect("search");
assert_eq!(results.len(), 1);
let found = &results[0];
let password = get_password(found).expect("password with comment");
assert_eq!(password.as_str(), "dromomeryx");
let attrs = found.get_attributes().expect("get_attributes");
assert_eq!(attrs.get("comment"), Some(&"note".to_string()));
assert!(attrs.contains_key("uuid"));
}
#[test]
fn build_with_comment_modifier_sets_comment() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = store.build(
"demo",
"alice",
Some(&HashMap::from([("comment", "initial")])),
)?;
set_password(&entry, "dromomeryx")?;
let attrs = entry.get_attributes()?;
assert_eq!(attrs.get("comment"), Some(&"initial".to_string()));
Ok(())
}
#[test]
fn in_memory_store_round_trip() -> Result<()> {
let config = DbKeyStoreConfig {
vfs: Some("memory".to_string()),
..Default::default()
};
let store = DbKeyStore::new(config)?;
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "dromomeryx")?;
let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
assert_eq!(results.len(), 1);
let password = get_password(&results[0])?;
assert_eq!(password.as_str(), "dromomeryx");
Ok(())
}
#[test]
fn stores_separate_service_user_pairs() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "myapp", "user1");
set_password(&entry, "pw1")?;
let entry = build_entry(&store, "myapp", "user2");
set_password(&entry, "pw2")?;
let entry = build_entry(&store, "myapp", "user3");
set_password(&entry, "pw3")?;
let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user1")]))?;
assert_eq!(results.len(), 1);
let password = get_password(&results[0])?;
assert_eq!(password.as_str(), "pw1");
let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user2")]))?;
assert_eq!(results.len(), 1);
let password = get_password(&results[0])?;
assert_eq!(password.as_str(), "pw2");
let results = store.search(&HashMap::from([("service", "myapp"), ("user", "user3")]))?;
assert_eq!(results.len(), 1);
let password = get_password(&results[0])?;
assert_eq!(password.as_str(), "pw3");
Ok(())
}
#[test]
fn search_regex() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "myapp", "user1");
set_password(&entry, "pw1")?;
let entry = build_entry(&store, "myapp", "user2");
set_password(&entry, "pw2")?;
let entry = build_entry(&store, "myapp", "user3");
set_password(&entry, "pw3")?;
let entry = build_entry(&store, "other-app", "user1");
set_password(&entry, "pw4")?;
let results = store.search(&HashMap::from([("service", ".*app"), ("user", "user1")]))?;
assert_eq!(results.len(), 2, "search *app, user1");
let results = store.search(&HashMap::from([
("service", "myapp"),
("user", "user1|user2"),
]))?;
assert_eq!(results.len(), 2, "search regex OR");
Ok(())
}
#[test]
fn search_partial() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let results = store.search(&HashMap::new())?;
assert_eq!(results.len(), 0, "empty db, no results");
let entry = build_entry(&store, "myapp", "user1");
set_password(&entry, "pw1")?;
let entry = build_entry(&store, "other-app", "user1");
set_password(&entry, "pw2")?;
let results = store.search(&HashMap::new())?;
assert_eq!(results.len(), 2, "search, empty hashmap");
let results = store.search(&HashMap::from([("service", "myapp")]))?;
assert_eq!(results.len(), 1, "search myapp");
let results = store.search(&HashMap::from([("user", "user1")]))?;
assert_eq!(results.len(), 2, "search user1");
Ok(())
}
#[test]
fn repeated_set_replaces_secret() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "first").expect("password set 1");
set_secret(&entry, b"second").expect("password set 2");
let mut spec = HashMap::new();
spec.insert("service", "demo");
spec.insert("user", "alice");
let results = store.search(&spec).expect("search");
assert_eq!(results.len(), 1);
let password = get_password(&results[0]).expect("get first password");
assert_eq!(
password.as_str(),
"second",
"second password overwrites first"
);
}
#[test]
fn same_service_user_entries_share_credential() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry1 = build_entry(&store, "demo", "alice");
let entry2 = build_entry(&store, "demo", "alice");
set_password(&entry1, "first")?;
let password = get_password(&entry2)?;
assert_eq!(password.as_str(), "first");
set_password(&entry2, "second")?;
let password = get_password(&entry1)?;
assert_eq!(password.as_str(), "second");
Ok(())
}
#[test]
fn remove_returns_no_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "demo", "alice");
set_password(&entry, "dromomeryx").expect("set password");
entry.delete_credential().expect("delete credential");
let err = entry.delete_credential().unwrap_err();
assert!(matches!(err, Error::NoEntry));
}
#[test]
fn remove_clears_secret() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let entry = build_entry(&store, "service", "user");
set_password(&entry, "dromomeryx").expect("set password");
entry.delete_credential().expect("delete credential");
let mut spec = HashMap::new();
spec.insert("service", "demo");
spec.insert("user", "alice");
let results = store.search(&spec).expect("search");
assert!(results.is_empty());
}
#[test]
fn allow_ambiguity_allows_multiple_entries_per_user() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let config = DbKeyStoreConfig {
path: path.clone(),
allow_ambiguity: true,
..Default::default()
};
let store = DbKeyStore::new(config)?;
let uuid1 = new_uuid();
let uuid2 = new_uuid();
let entry1 = store.build(
"demo",
"alice",
Some(&HashMap::from([
("uuid", uuid1.as_str()),
("comment", "one"),
])),
)?;
let entry2 = store.build(
"demo",
"alice",
Some(&HashMap::from([
("uuid", uuid2.as_str()),
("comment", "two"),
])),
)?;
set_password(&entry1, "first")?;
set_password(&entry2, "second")?;
let results = store.search(&HashMap::from([("service", "demo"), ("user", "alice")]))?;
assert_eq!(results.len(), 2);
let entry3 = build_entry(&store, "demo", "alice");
let err = entry3.get_password().unwrap_err();
assert!(matches!(err, Error::Ambiguous(_)));
Ok(())
}
#[test]
fn duplicate_uuid_across_service_user_is_scoped() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let config = DbKeyStoreConfig {
path: path.clone(),
allow_ambiguity: true,
..Default::default()
};
let store = DbKeyStore::new(config)?;
let uuid = "f81d4fae-7dec-11d0-a765-00a0c91e6bf6";
let entry1 = store.build(
"service-a",
"user-a",
Some(&HashMap::from([("uuid", uuid)])),
)?;
let entry2 = store.build(
"service-b",
"user-b",
Some(&HashMap::from([("uuid", uuid)])),
)?;
set_password(&entry1, "pw1")?;
set_password(&entry2, "pw2")?;
entry1.update_attributes(&HashMap::from([("comment", "note1")]))?;
let attrs1 = entry1.get_attributes()?;
assert_eq!(attrs1.get("comment"), Some(&"note1".to_string()));
let attrs2 = entry2.get_attributes()?;
assert!(!attrs2.contains_key("comment"));
entry1.delete_credential()?;
let pw2 = get_password(&entry2)?;
assert_eq!(pw2.as_str(), "pw2");
Ok(())
}
#[test]
fn disallow_ambiguity_rejects_duplicate_uuid_entries() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore.db");
let store = new_store(&path);
let uuid1 = new_uuid();
let uuid2 = new_uuid();
let entry1 = store.build(
"demo",
"alice",
Some(&HashMap::from([("uuid", uuid1.as_str())])),
)?;
let entry2 = store.build(
"demo",
"alice",
Some(&HashMap::from([("uuid", uuid2.as_str())])),
)?;
set_password(&entry1, "first")?;
let err = set_password(&entry2, "second").unwrap_err();
assert!(matches!(err, Error::Invalid(key, _) if key == "uuid"));
Ok(())
}
#[test]
fn impl_debug() -> Result<()> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keystore1.db");
let store = new_store(&path);
eprintln!("basic: {store:?}");
let path = dir.path().join("keystore2.db");
let config = DbKeyStoreConfig {
path: path.clone(),
encryption_opts: Some(EncryptionOpts::new(
"aes256gcm",
"0000000011111111222222223333333344444444555555556666666677777777",
)),
..Default::default()
};
let store = DbKeyStore::new(config)?;
eprintln!("with_enc: {store:?}");
let config = DbKeyStoreConfig {
vfs: Some("memory".to_string()),
..Default::default()
};
let store = DbKeyStore::new(config)?;
eprintln!("memory: {store:?}");
Ok(())
}
#[test]
fn uuid_v7_strings_are_lexicographically_increasing() {
let mut uuids = Vec::new();
for _ in 0..8 {
uuids.push(new_uuid());
}
for pair in uuids.windows(2) {
assert!(
pair[0] < pair[1],
"uuid v7 strings should be lexicographically increasing"
);
}
}
}