commit 6b22c7078a6cf1918372777ec644b6dba4e23d09
parent f2a923a757420875d6cefe5e62433686238b9e4b
Author: triesap <triesap@radroots.dev>
Date: Mon, 19 Jan 2026 01:28:35 +0000
app-core: add backup bundle builders
- add bundle opts, errors, and build/export/import helpers
- collect store payloads and manifest metadata for backups
- map store/crypto errors into bundle results
- add async tests and futures dev-dependency
Diffstat:
4 files changed, 453 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1162,6 +1162,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"base64",
+ "futures",
"getrandom 0.2.17",
"js-sys",
"serde",
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
@@ -20,3 +20,6 @@ base64 = { workspace = true }
js-sys = { workspace = true }
web-sys = { workspace = true }
wasm-bindgen-futures = { workspace = true }
+
+[dev-dependencies]
+futures = "0.3"
diff --git a/crates/core/src/backup/bundle.rs b/crates/core/src/backup/bundle.rs
@@ -0,0 +1,439 @@
+use std::fmt;
+
+use crate::crypto::{
+ RadrootsClientCryptoError,
+ RadrootsClientCryptoRegistryExport,
+ RadrootsClientKeyMaterialProvider,
+ RadrootsClientWebCryptoService,
+};
+
+use super::{
+ backup_bundle_decode,
+ backup_bundle_encode,
+ RadrootsClientBackupBundle,
+ RadrootsClientBackupBundleManifest,
+ RadrootsClientBackupBundlePayload,
+ RadrootsClientBackupDatastoreStore,
+ RadrootsClientBackupError,
+ RadrootsClientBackupKeystoreStore,
+ RadrootsClientBackupSqlStore,
+ RadrootsClientBackupStoreRef,
+ RADROOTS_CLIENT_BACKUP_BUNDLE_VERSION,
+};
+
+#[derive(Debug)]
+pub enum RadrootsClientBackupBundleError<SqlErr, KeystoreErr, DatastoreErr> {
+ Backup(RadrootsClientBackupError),
+ Crypto(RadrootsClientCryptoError),
+ Sql(SqlErr),
+ Keystore(KeystoreErr),
+ Datastore(DatastoreErr),
+}
+
+impl<SqlErr: fmt::Display, KeystoreErr: fmt::Display, DatastoreErr: fmt::Display> fmt::Display
+ for RadrootsClientBackupBundleError<SqlErr, KeystoreErr, DatastoreErr>
+{
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ RadrootsClientBackupBundleError::Backup(err) => err.fmt(f),
+ RadrootsClientBackupBundleError::Crypto(err) => err.fmt(f),
+ RadrootsClientBackupBundleError::Sql(err) => err.fmt(f),
+ RadrootsClientBackupBundleError::Keystore(err) => err.fmt(f),
+ RadrootsClientBackupBundleError::Datastore(err) => err.fmt(f),
+ }
+ }
+}
+
+impl<SqlErr, KeystoreErr, DatastoreErr> std::error::Error
+ for RadrootsClientBackupBundleError<SqlErr, KeystoreErr, DatastoreErr>
+where
+ SqlErr: std::error::Error + 'static,
+ KeystoreErr: std::error::Error + 'static,
+ DatastoreErr: std::error::Error + 'static,
+{
+}
+
+pub type RadrootsClientBackupBundleResult<T, SqlErr, KeystoreErr, DatastoreErr> =
+ Result<T, RadrootsClientBackupBundleError<SqlErr, KeystoreErr, DatastoreErr>>;
+
+pub struct RadrootsClientBackupBundleBuildOpts<'a, SqlStore, KeystoreStore, DatastoreStore>
+where
+ SqlStore: RadrootsClientBackupSqlStore + ?Sized,
+ KeystoreStore: RadrootsClientBackupKeystoreStore + ?Sized,
+ DatastoreStore: RadrootsClientBackupDatastoreStore + ?Sized,
+{
+ pub sql_store: Option<&'a SqlStore>,
+ pub keystore_store: Option<&'a KeystoreStore>,
+ pub datastore_store: Option<&'a DatastoreStore>,
+ pub app_version: Option<&'a str>,
+ pub crypto_service: Option<&'a dyn RadrootsClientWebCryptoService>,
+ pub key_material_provider: Option<&'a dyn RadrootsClientKeyMaterialProvider>,
+}
+
+pub struct RadrootsClientBackupBundleImportOpts<'a, SqlStore, KeystoreStore, DatastoreStore>
+where
+ SqlStore: RadrootsClientBackupSqlStore + ?Sized,
+ KeystoreStore: RadrootsClientBackupKeystoreStore + ?Sized,
+ DatastoreStore: RadrootsClientBackupDatastoreStore + ?Sized,
+{
+ pub sql_store: Option<&'a SqlStore>,
+ pub keystore_store: Option<&'a KeystoreStore>,
+ pub datastore_store: Option<&'a DatastoreStore>,
+ pub crypto_service: Option<&'a dyn RadrootsClientWebCryptoService>,
+ pub key_material_provider: Option<&'a dyn RadrootsClientKeyMaterialProvider>,
+ pub import_registry: bool,
+}
+
+fn now_millis() -> u64 {
+ #[cfg(target_arch = "wasm32")]
+ {
+ return js_sys::Date::now() as u64;
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ return SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_millis() as u64)
+ .unwrap_or(0);
+ }
+}
+
+async fn collect_payloads<SqlStore, KeystoreStore, DatastoreStore>(
+ opts: &RadrootsClientBackupBundleBuildOpts<'_, SqlStore, KeystoreStore, DatastoreStore>,
+) -> RadrootsClientBackupBundleResult<
+ Vec<RadrootsClientBackupBundlePayload>,
+ SqlStore::Error,
+ KeystoreStore::Error,
+ DatastoreStore::Error,
+>
+where
+ SqlStore: RadrootsClientBackupSqlStore + ?Sized,
+ KeystoreStore: RadrootsClientBackupKeystoreStore + ?Sized,
+ DatastoreStore: RadrootsClientBackupDatastoreStore + ?Sized,
+{
+ let mut payloads = Vec::new();
+ if let Some(store) = opts.sql_store {
+ let data = store
+ .export_backup()
+ .await
+ .map_err(RadrootsClientBackupBundleError::Sql)?;
+ payloads.push(RadrootsClientBackupBundlePayload::Sql {
+ store_id: store.store_id().to_string(),
+ data,
+ });
+ }
+ if let Some(store) = opts.keystore_store {
+ let data = store
+ .export_backup()
+ .await
+ .map_err(RadrootsClientBackupBundleError::Keystore)?;
+ payloads.push(RadrootsClientBackupBundlePayload::Keystore {
+ store_id: store.store_id().to_string(),
+ data,
+ });
+ }
+ if let Some(store) = opts.datastore_store {
+ let data = store
+ .export_backup()
+ .await
+ .map_err(RadrootsClientBackupBundleError::Datastore)?;
+ payloads.push(RadrootsClientBackupBundlePayload::Datastore {
+ store_id: store.store_id().to_string(),
+ data,
+ });
+ }
+ Ok(payloads)
+}
+
+pub async fn backup_bundle_build<SqlStore, KeystoreStore, DatastoreStore>(
+ opts: &RadrootsClientBackupBundleBuildOpts<'_, SqlStore, KeystoreStore, DatastoreStore>,
+) -> RadrootsClientBackupBundleResult<
+ RadrootsClientBackupBundle,
+ SqlStore::Error,
+ KeystoreStore::Error,
+ DatastoreStore::Error,
+>
+where
+ SqlStore: RadrootsClientBackupSqlStore + ?Sized,
+ KeystoreStore: RadrootsClientBackupKeystoreStore + ?Sized,
+ DatastoreStore: RadrootsClientBackupDatastoreStore + ?Sized,
+{
+ let payloads = collect_payloads(opts).await?;
+ let stores = payloads
+ .iter()
+ .map(|payload| RadrootsClientBackupStoreRef {
+ store_id: payload.store_id().to_string(),
+ store_type: payload.store_type(),
+ })
+ .collect();
+ let crypto_registry = match opts.crypto_service {
+ Some(crypto) => crypto
+ .export_registry()
+ .await
+ .map_err(RadrootsClientBackupBundleError::Crypto)?,
+ None => RadrootsClientCryptoRegistryExport {
+ stores: Vec::new(),
+ keys: Vec::new(),
+ },
+ };
+ Ok(RadrootsClientBackupBundle {
+ manifest: RadrootsClientBackupBundleManifest {
+ version: RADROOTS_CLIENT_BACKUP_BUNDLE_VERSION,
+ created_at: now_millis(),
+ app_version: opts.app_version.map(str::to_string),
+ stores,
+ crypto_registry,
+ },
+ payloads,
+ })
+}
+
+pub async fn backup_bundle_export<SqlStore, KeystoreStore, DatastoreStore>(
+ opts: &RadrootsClientBackupBundleBuildOpts<'_, SqlStore, KeystoreStore, DatastoreStore>,
+) -> RadrootsClientBackupBundleResult<
+ Vec<u8>,
+ SqlStore::Error,
+ KeystoreStore::Error,
+ DatastoreStore::Error,
+>
+where
+ SqlStore: RadrootsClientBackupSqlStore + ?Sized,
+ KeystoreStore: RadrootsClientBackupKeystoreStore + ?Sized,
+ DatastoreStore: RadrootsClientBackupDatastoreStore + ?Sized,
+{
+ let provider = opts
+ .key_material_provider
+ .ok_or(RadrootsClientBackupBundleError::Backup(
+ RadrootsClientBackupError::ProviderMissing,
+ ))?;
+ let bundle = backup_bundle_build(opts).await?;
+ backup_bundle_encode(&bundle, provider)
+ .await
+ .map_err(RadrootsClientBackupBundleError::Backup)
+}
+
+pub async fn backup_bundle_import<SqlStore, KeystoreStore, DatastoreStore>(
+ blob: &[u8],
+ opts: &RadrootsClientBackupBundleImportOpts<'_, SqlStore, KeystoreStore, DatastoreStore>,
+) -> RadrootsClientBackupBundleResult<
+ RadrootsClientBackupBundle,
+ SqlStore::Error,
+ KeystoreStore::Error,
+ DatastoreStore::Error,
+>
+where
+ SqlStore: RadrootsClientBackupSqlStore + ?Sized,
+ KeystoreStore: RadrootsClientBackupKeystoreStore + ?Sized,
+ DatastoreStore: RadrootsClientBackupDatastoreStore + ?Sized,
+{
+ let provider = opts
+ .key_material_provider
+ .ok_or(RadrootsClientBackupBundleError::Backup(
+ RadrootsClientBackupError::ProviderMissing,
+ ))?;
+ let bundle = backup_bundle_decode(blob, provider)
+ .await
+ .map_err(RadrootsClientBackupBundleError::Backup)?;
+ if opts.import_registry {
+ if let Some(crypto) = opts.crypto_service {
+ crypto
+ .import_registry(bundle.manifest.crypto_registry.clone())
+ .await
+ .map_err(RadrootsClientBackupBundleError::Crypto)?;
+ }
+ }
+ for payload in &bundle.payloads {
+ match payload {
+ RadrootsClientBackupBundlePayload::Sql { store_id, data } => {
+ if let Some(store) = opts.sql_store {
+ if store.store_id() == store_id {
+ store
+ .import_backup(data.clone())
+ .await
+ .map_err(RadrootsClientBackupBundleError::Sql)?;
+ }
+ }
+ }
+ RadrootsClientBackupBundlePayload::Keystore { store_id, data } => {
+ if let Some(store) = opts.keystore_store {
+ if store.store_id() == store_id {
+ store
+ .import_backup(data.clone())
+ .await
+ .map_err(RadrootsClientBackupBundleError::Keystore)?;
+ }
+ }
+ }
+ RadrootsClientBackupBundlePayload::Datastore { store_id, data } => {
+ if let Some(store) = opts.datastore_store {
+ if store.store_id() == store_id {
+ store
+ .import_backup(data.clone())
+ .await
+ .map_err(RadrootsClientBackupBundleError::Datastore)?;
+ }
+ }
+ }
+ }
+ }
+ Ok(bundle)
+}
+
+#[cfg(test)]
+mod tests {
+ use async_trait::async_trait;
+
+ use super::{
+ backup_bundle_build,
+ backup_bundle_export,
+ RadrootsClientBackupBundleBuildOpts,
+ RadrootsClientBackupBundleError,
+ };
+ use crate::backup::{
+ RadrootsClientBackupBundlePayload,
+ RadrootsClientBackupDatastorePayload,
+ RadrootsClientBackupDatastoreStore,
+ RadrootsClientBackupError,
+ RadrootsClientBackupKeystorePayload,
+ RadrootsClientBackupKeystoreStore,
+ RadrootsClientBackupSqlPayload,
+ RadrootsClientBackupSqlStore,
+ };
+
+ #[derive(Debug)]
+ struct StubError;
+
+ impl std::fmt::Display for StubError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("stub")
+ }
+ }
+
+ impl std::error::Error for StubError {}
+
+ struct StubSqlStore;
+ struct StubKeystoreStore;
+ struct StubDatastoreStore;
+
+ #[async_trait(?Send)]
+ impl RadrootsClientBackupSqlStore for StubSqlStore {
+ type Error = StubError;
+
+ async fn export_backup(&self) -> Result<RadrootsClientBackupSqlPayload, Self::Error> {
+ Ok(RadrootsClientBackupSqlPayload {
+ bytes_b64: "sql".to_string(),
+ })
+ }
+
+ async fn import_backup(
+ &self,
+ _payload: RadrootsClientBackupSqlPayload,
+ ) -> Result<(), Self::Error> {
+ Ok(())
+ }
+
+ fn store_id(&self) -> &str {
+ "sql-store"
+ }
+ }
+
+ #[async_trait(?Send)]
+ impl RadrootsClientBackupKeystoreStore for StubKeystoreStore {
+ type Error = StubError;
+
+ async fn export_backup(
+ &self,
+ ) -> Result<RadrootsClientBackupKeystorePayload, Self::Error> {
+ Ok(RadrootsClientBackupKeystorePayload {
+ entries: Vec::new(),
+ })
+ }
+
+ async fn import_backup(
+ &self,
+ _payload: RadrootsClientBackupKeystorePayload,
+ ) -> Result<(), Self::Error> {
+ Ok(())
+ }
+
+ fn store_id(&self) -> &str {
+ "keystore"
+ }
+ }
+
+ #[async_trait(?Send)]
+ impl RadrootsClientBackupDatastoreStore for StubDatastoreStore {
+ type Error = StubError;
+
+ async fn export_backup(
+ &self,
+ ) -> Result<RadrootsClientBackupDatastorePayload, Self::Error> {
+ Ok(RadrootsClientBackupDatastorePayload {
+ entries: Vec::new(),
+ })
+ }
+
+ async fn import_backup(
+ &self,
+ _payload: RadrootsClientBackupDatastorePayload,
+ ) -> Result<(), Self::Error> {
+ Ok(())
+ }
+
+ fn store_id(&self) -> &str {
+ "datastore"
+ }
+ }
+
+ #[test]
+ fn build_collects_payloads() {
+ let sql = StubSqlStore;
+ let keystore = StubKeystoreStore;
+ let datastore = StubDatastoreStore;
+ let opts = RadrootsClientBackupBundleBuildOpts {
+ sql_store: Some(&sql),
+ keystore_store: Some(&keystore),
+ datastore_store: Some(&datastore),
+ app_version: Some("1.2.3"),
+ crypto_service: None,
+ key_material_provider: None,
+ };
+ let bundle = futures::executor::block_on(backup_bundle_build(&opts))
+ .expect("bundle");
+ assert_eq!(bundle.payloads.len(), 3);
+ assert_eq!(bundle.manifest.stores.len(), 3);
+ assert_eq!(bundle.manifest.app_version.as_deref(), Some("1.2.3"));
+ assert!(bundle.manifest.crypto_registry.stores.is_empty());
+ assert!(bundle.manifest.crypto_registry.keys.is_empty());
+ assert!(matches!(
+ bundle.payloads[0],
+ RadrootsClientBackupBundlePayload::Sql { .. }
+ ));
+ }
+
+ #[test]
+ fn export_requires_provider() {
+ let sql = StubSqlStore;
+ let opts: RadrootsClientBackupBundleBuildOpts<
+ StubSqlStore,
+ StubKeystoreStore,
+ StubDatastoreStore,
+ > = RadrootsClientBackupBundleBuildOpts {
+ sql_store: Some(&sql),
+ keystore_store: None,
+ datastore_store: None,
+ app_version: None,
+ crypto_service: None,
+ key_material_provider: None,
+ };
+ let err = futures::executor::block_on(backup_bundle_export(&opts))
+ .expect_err("missing provider");
+ match err {
+ RadrootsClientBackupBundleError::Backup(
+ RadrootsClientBackupError::ProviderMissing,
+ ) => {}
+ other => panic!("unexpected error: {other}"),
+ }
+ }
+}
diff --git a/crates/core/src/backup/mod.rs b/crates/core/src/backup/mod.rs
@@ -1,6 +1,7 @@
pub mod error;
pub mod types;
pub mod codec;
+pub mod bundle;
pub use error::{RadrootsClientBackupError, RadrootsClientBackupErrorMessage};
pub use types::{
@@ -21,6 +22,15 @@ pub use types::{
RadrootsClientBackupStoreRef,
RADROOTS_CLIENT_BACKUP_BUNDLE_VERSION,
};
+pub use bundle::{
+ backup_bundle_build,
+ backup_bundle_export,
+ backup_bundle_import,
+ RadrootsClientBackupBundleBuildOpts,
+ RadrootsClientBackupBundleError,
+ RadrootsClientBackupBundleImportOpts,
+ RadrootsClientBackupBundleResult,
+};
pub use codec::{
backup_b64_to_bytes,
backup_bytes_to_b64,