tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

backup.rs (31094B)


      1 #![forbid(unsafe_code)]
      2 
      3 use crate::{TANGLE_RELAY_VERSION, config::TenantRuntimeConfig, load_tangle_host_runtime_config};
      4 use serde::{Deserialize, Serialize};
      5 use sha2::{Digest, Sha256};
      6 use std::{
      7     fs,
      8     io::Read,
      9     path::{Component, Path, PathBuf},
     10     time::{SystemTime, UNIX_EPOCH},
     11 };
     12 use tangle_store_pocket::{PocketStoreConfig, PocketStoreHandle};
     13 
     14 pub const TANGLE_SPEC_VERSION: &str = "tangle_v1_mvp";
     15 const BACKUP_SCHEMA: &str = "tangle.tenant.backup.v1";
     16 const CHECKSUM_SCHEMA: &str = "tangle.tenant.checksums.v1";
     17 const POCKET_STORE_DIR: &str = "pocket_store";
     18 const REDACTED_TENANT_CONFIG: &str = "tenant_config.redacted.json";
     19 const BACKUP_MANIFEST: &str = "backup_manifest.json";
     20 const CHECKSUM_MANIFEST: &str = "checksums.json";
     21 
     22 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     23 pub struct TenantBackupRequest<'a> {
     24     pub config_path: &'a str,
     25     pub tenant_id: &'a str,
     26     pub output: &'a str,
     27     pub include_secrets: bool,
     28 }
     29 
     30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     31 pub struct TenantRestoreRequest<'a> {
     32     pub config_path: &'a str,
     33     pub tenant_id: &'a str,
     34     pub input: &'a str,
     35     pub target_data_dir: &'a str,
     36 }
     37 
     38 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     39 pub struct TenantBackupReport {
     40     pub tenant_id: String,
     41     pub output_path: String,
     42     pub manifest_path: String,
     43     pub checksum_manifest_path: String,
     44     pub checksum_file_count: usize,
     45 }
     46 
     47 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     48 pub struct TenantRestoreReport {
     49     pub tenant_id: String,
     50     pub input_path: String,
     51     pub target_data_dir: String,
     52     pub restored_file_count: usize,
     53 }
     54 
     55 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     56 pub(crate) struct ChecksumManifest {
     57     schema: String,
     58     algorithm: String,
     59     files: Vec<ChecksumFile>,
     60 }
     61 
     62 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     63 pub(crate) struct ChecksumFile {
     64     path: String,
     65     sha256: String,
     66     size_bytes: u64,
     67 }
     68 
     69 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     70 struct TenantBackupManifest {
     71     schema: String,
     72     tangle_version: String,
     73     tangle_spec_version: String,
     74     created_at: u64,
     75     source: TenantManifestSource,
     76     store: TenantStoreManifest,
     77     redacted_tenant_config_path: String,
     78     checksum_manifest_path: String,
     79     checksum_manifest_sha256: String,
     80     checksum_file_count: usize,
     81     includes_secrets: bool,
     82 }
     83 
     84 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     85 pub(crate) struct TenantManifestSource {
     86     tenant_id: String,
     87     tenant_schema: String,
     88     host: String,
     89     relay_url: String,
     90     relay_self_pubkey: Option<String>,
     91 }
     92 
     93 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     94 struct TenantStoreManifest {
     95     source_data_directory: String,
     96     snapshot_path: String,
     97 }
     98 
     99 pub fn backup_tenant(request: TenantBackupRequest<'_>) -> Result<TenantBackupReport, String> {
    100     if request.include_secrets {
    101         return Err("including tenant secrets in backups is unsupported".to_owned());
    102     }
    103     let tenant = load_selected_tenant_config(request.config_path, request.tenant_id)?;
    104     if !tenant.backup_export().backup_enabled() {
    105         return Err(format!(
    106             "tenant backup is disabled for {}",
    107             tenant.tenant_id().as_str()
    108         ));
    109     }
    110     let output = PathBuf::from(request.output);
    111     prepare_empty_directory(&output, "backup output")?;
    112     let snapshot_path = output.join(POCKET_STORE_DIR);
    113     copy_directory(tenant.pocket_config().data_directory(), &snapshot_path)?;
    114     let redacted_path = output.join(REDACTED_TENANT_CONFIG);
    115     write_json_file(&redacted_path, &redacted_tenant_config_value(&tenant)?)?;
    116     let checksums = ChecksumManifest {
    117         schema: CHECKSUM_SCHEMA.to_owned(),
    118         algorithm: "sha256".to_owned(),
    119         files: collect_checksums(&output)?,
    120     };
    121     let checksum_path = output.join(CHECKSUM_MANIFEST);
    122     write_json_file(&checksum_path, &checksums)?;
    123     let (checksum_manifest_sha256, _) = file_sha256_hex(&checksum_path)?;
    124     let source = tenant_manifest_source(&tenant)?;
    125     let manifest = TenantBackupManifest {
    126         schema: BACKUP_SCHEMA.to_owned(),
    127         tangle_version: TANGLE_RELAY_VERSION.to_owned(),
    128         tangle_spec_version: TANGLE_SPEC_VERSION.to_owned(),
    129         created_at: now_unix_seconds()?,
    130         source,
    131         store: TenantStoreManifest {
    132             source_data_directory: tenant
    133                 .pocket_config()
    134                 .data_directory()
    135                 .display()
    136                 .to_string(),
    137             snapshot_path: POCKET_STORE_DIR.to_owned(),
    138         },
    139         redacted_tenant_config_path: REDACTED_TENANT_CONFIG.to_owned(),
    140         checksum_manifest_path: CHECKSUM_MANIFEST.to_owned(),
    141         checksum_manifest_sha256,
    142         checksum_file_count: checksums.files.len(),
    143         includes_secrets: false,
    144     };
    145     let manifest_path = output.join(BACKUP_MANIFEST);
    146     write_json_file(&manifest_path, &manifest)?;
    147     Ok(TenantBackupReport {
    148         tenant_id: tenant.tenant_id().as_str().to_owned(),
    149         output_path: output.display().to_string(),
    150         manifest_path: manifest_path.display().to_string(),
    151         checksum_manifest_path: checksum_path.display().to_string(),
    152         checksum_file_count: checksums.files.len(),
    153     })
    154 }
    155 
    156 pub fn restore_tenant(request: TenantRestoreRequest<'_>) -> Result<TenantRestoreReport, String> {
    157     let tenant = load_selected_tenant_config(request.config_path, request.tenant_id)?;
    158     if !tenant.backup_export().backup_enabled() {
    159         return Err(format!(
    160             "tenant backup is disabled for {}",
    161             tenant.tenant_id().as_str()
    162         ));
    163     }
    164     let input = PathBuf::from(request.input);
    165     let manifest = read_backup_manifest(&input.join(BACKUP_MANIFEST))?;
    166     if manifest.schema != BACKUP_SCHEMA {
    167         return Err(format!("unsupported backup schema: {}", manifest.schema));
    168     }
    169     if manifest.source.tenant_id != tenant.tenant_id().as_str() {
    170         return Err(format!(
    171             "backup tenant {} does not match requested tenant {}",
    172             manifest.source.tenant_id,
    173             tenant.tenant_id().as_str()
    174         ));
    175     }
    176     let checksum_path = input.join(&manifest.checksum_manifest_path);
    177     let (actual_checksum_manifest_sha256, _) = file_sha256_hex(&checksum_path)?;
    178     if actual_checksum_manifest_sha256 != manifest.checksum_manifest_sha256 {
    179         return Err("backup checksum manifest digest mismatch".to_owned());
    180     }
    181     let checksum_manifest = read_checksum_manifest(&checksum_path)?;
    182     verify_checksums(&input, &checksum_manifest.files)?;
    183     let target = PathBuf::from(request.target_data_dir);
    184     prepare_empty_directory(&target, "restore target data directory")?;
    185     copy_directory(&input.join(&manifest.store.snapshot_path), &target)?;
    186     let restored_config = PocketStoreConfig::new(&target, tenant.pocket_config().sync_policy())
    187         .map_err(|error| error.to_string())?;
    188     let restored = PocketStoreHandle::open(&restored_config).map_err(|error| error.to_string())?;
    189     let restored_file_count = collect_files(&target)?.len();
    190     restored.scan_events().map_err(|error| error.to_string())?;
    191     Ok(TenantRestoreReport {
    192         tenant_id: tenant.tenant_id().as_str().to_owned(),
    193         input_path: input.display().to_string(),
    194         target_data_dir: target.display().to_string(),
    195         restored_file_count,
    196     })
    197 }
    198 
    199 pub(crate) fn load_selected_tenant_config(
    200     config_path: &str,
    201     tenant_id: &str,
    202 ) -> Result<TenantRuntimeConfig, String> {
    203     let config = load_tangle_host_runtime_config(config_path).map_err(|error| error.to_string())?;
    204     config
    205         .tenants()
    206         .iter()
    207         .find(|tenant| tenant.tenant_id().as_str() == tenant_id)
    208         .cloned()
    209         .ok_or_else(|| format!("tenant not found: {tenant_id}"))
    210 }
    211 
    212 pub(crate) fn tenant_manifest_source(
    213     tenant: &TenantRuntimeConfig,
    214 ) -> Result<TenantManifestSource, String> {
    215     Ok(TenantManifestSource {
    216         tenant_id: tenant.tenant_id().as_str().to_owned(),
    217         tenant_schema: tenant.tenant_schema().as_str().to_owned(),
    218         host: tenant.host().as_str().to_owned(),
    219         relay_url: tenant.relay_url().as_str().to_owned(),
    220         relay_self_pubkey: tenant
    221             .relay_self_pubkey()
    222             .map_err(|error| error.to_string())?
    223             .map(|pubkey| pubkey.as_str().to_owned()),
    224     })
    225 }
    226 
    227 pub(crate) fn redacted_tenant_config_value(
    228     tenant: &TenantRuntimeConfig,
    229 ) -> Result<serde_json::Value, String> {
    230     Ok(serde_json::json!({
    231         "tenant_id": tenant.tenant_id().as_str(),
    232         "tenant_schema": tenant.tenant_schema().as_str(),
    233         "host": tenant.host().as_str(),
    234         "relay_url": tenant.relay_url().as_str(),
    235         "inactive": tenant.inactive(),
    236         "info": {
    237             "name": tenant.info().name(),
    238             "description": tenant.info().description(),
    239             "contact": tenant.info().contact(),
    240             "icon": tenant.info().icon()
    241         },
    242         "pocket": {
    243             "data_directory": tenant.pocket_config().data_directory().display().to_string(),
    244             "sync_policy": format!("{:?}", tenant.pocket_config().sync_policy())
    245         },
    246         "groups": {
    247             "enabled": tenant.groups().enabled(),
    248             "relay_secret": "<redacted>",
    249             "relay_self": tenant.relay_self_pubkey().map_err(|error| error.to_string())?.map(|pubkey| pubkey.as_str().to_owned())
    250         },
    251         "backup_export": {
    252             "backup_enabled": tenant.backup_export().backup_enabled(),
    253             "export_enabled": tenant.backup_export().export_enabled()
    254         }
    255     }))
    256 }
    257 
    258 pub(crate) fn write_json_file<T>(path: &Path, value: &T) -> Result<(), String>
    259 where
    260     T: Serialize,
    261 {
    262     if let Some(parent) = path.parent()
    263         && !parent.as_os_str().is_empty()
    264     {
    265         fs::create_dir_all(parent)
    266             .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
    267     }
    268     let raw = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?;
    269     fs::write(path, raw).map_err(|error| format!("failed to write {}: {error}", path.display()))
    270 }
    271 
    272 pub(crate) fn file_sha256_hex(path: &Path) -> Result<(String, u64), String> {
    273     let mut file = fs::File::open(path)
    274         .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
    275     let mut hasher = Sha256::new();
    276     let mut size = 0_u64;
    277     let mut buffer = [0_u8; 16 * 1024];
    278     loop {
    279         let read = file
    280             .read(&mut buffer)
    281             .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    282         if read == 0 {
    283             break;
    284         }
    285         hasher.update(&buffer[..read]);
    286         size = size
    287             .checked_add(u64::try_from(read).expect("read size fits u64"))
    288             .ok_or_else(|| format!("file {} exceeds u64 size", path.display()))?;
    289     }
    290     Ok((lower_hex(&hasher.finalize()), size))
    291 }
    292 
    293 pub(crate) fn now_unix_seconds() -> Result<u64, String> {
    294     SystemTime::now()
    295         .duration_since(UNIX_EPOCH)
    296         .map(|duration| duration.as_secs())
    297         .map_err(|error| error.to_string())
    298 }
    299 
    300 pub(crate) fn collect_files(root: &Path) -> Result<Vec<PathBuf>, String> {
    301     let mut files = Vec::new();
    302     collect_files_into(root, root, &mut files)?;
    303     files.sort();
    304     Ok(files)
    305 }
    306 
    307 pub(crate) fn lower_hex(bytes: &[u8]) -> String {
    308     const HEX: &[u8; 16] = b"0123456789abcdef";
    309     let mut output = String::with_capacity(bytes.len() * 2);
    310     for byte in bytes {
    311         output.push(char::from(HEX[usize::from(byte >> 4)]));
    312         output.push(char::from(HEX[usize::from(byte & 0x0f)]));
    313     }
    314     output
    315 }
    316 
    317 fn prepare_empty_directory(path: &Path, label: &str) -> Result<(), String> {
    318     if path.exists() {
    319         if !path.is_dir() {
    320             return Err(format!("{label} is not a directory: {}", path.display()));
    321         }
    322         if fs::read_dir(path)
    323             .map_err(|error| format!("failed to read {}: {error}", path.display()))?
    324             .next()
    325             .transpose()
    326             .map_err(|error| format!("failed to read {}: {error}", path.display()))?
    327             .is_some()
    328         {
    329             return Err(format!("{label} must be empty: {}", path.display()));
    330         }
    331     }
    332     fs::create_dir_all(path)
    333         .map_err(|error| format!("failed to create {}: {error}", path.display()))
    334 }
    335 
    336 fn copy_directory(source: &Path, target: &Path) -> Result<(), String> {
    337     if !source.is_dir() {
    338         return Err(format!(
    339             "source directory does not exist: {}",
    340             source.display()
    341         ));
    342     }
    343     fs::create_dir_all(target)
    344         .map_err(|error| format!("failed to create {}: {error}", target.display()))?;
    345     let mut entries = fs::read_dir(source)
    346         .map_err(|error| format!("failed to read {}: {error}", source.display()))?
    347         .collect::<Result<Vec<_>, _>>()
    348         .map_err(|error| format!("failed to read {}: {error}", source.display()))?;
    349     entries.sort_by_key(|entry| entry.path());
    350     for entry in entries {
    351         let source_path = entry.path();
    352         let target_path = target.join(entry.file_name());
    353         let metadata = fs::symlink_metadata(&source_path)
    354             .map_err(|error| format!("failed to stat {}: {error}", source_path.display()))?;
    355         let file_type = metadata.file_type();
    356         if file_type.is_symlink() {
    357             return Err(format!(
    358                 "symlink is not supported in backup bundles: {}",
    359                 source_path.display()
    360             ));
    361         }
    362         if file_type.is_dir() {
    363             copy_directory(&source_path, &target_path)?;
    364         } else if file_type.is_file() {
    365             fs::copy(&source_path, &target_path).map_err(|error| {
    366                 format!(
    367                     "failed to copy {} to {}: {error}",
    368                     source_path.display(),
    369                     target_path.display()
    370                 )
    371             })?;
    372         } else {
    373             return Err(format!(
    374                 "special file is not supported in backup bundles: {}",
    375                 source_path.display()
    376             ));
    377         }
    378     }
    379     Ok(())
    380 }
    381 
    382 fn collect_checksums(root: &Path) -> Result<Vec<ChecksumFile>, String> {
    383     collect_files(root)?
    384         .into_iter()
    385         .map(|path| {
    386             let relative = path
    387                 .strip_prefix(root)
    388                 .map_err(|error| error.to_string())
    389                 .and_then(relative_path_string)?;
    390             let (sha256, size_bytes) = file_sha256_hex(&path)?;
    391             Ok(ChecksumFile {
    392                 path: relative,
    393                 sha256,
    394                 size_bytes,
    395             })
    396         })
    397         .collect()
    398 }
    399 
    400 fn collect_files_into(root: &Path, path: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
    401     if !path.exists() {
    402         return Ok(());
    403     }
    404     let metadata = fs::symlink_metadata(path)
    405         .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
    406     let file_type = metadata.file_type();
    407     if file_type.is_symlink() {
    408         return Err(format!(
    409             "symlink is not supported in backup bundles: {}",
    410             path.display()
    411         ));
    412     }
    413     if file_type.is_file() {
    414         files.push(path.to_path_buf());
    415         return Ok(());
    416     }
    417     if !file_type.is_dir() {
    418         return Err(format!(
    419             "special file is not supported in backup bundles: {}",
    420             path.display()
    421         ));
    422     }
    423     let mut entries = fs::read_dir(path)
    424         .map_err(|error| format!("failed to read {}: {error}", path.display()))?
    425         .collect::<Result<Vec<_>, _>>()
    426         .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    427     entries.sort_by_key(|entry| entry.path());
    428     for entry in entries {
    429         let child = entry.path();
    430         if child == root.join(BACKUP_MANIFEST) || child == root.join(CHECKSUM_MANIFEST) {
    431             continue;
    432         }
    433         collect_files_into(root, &child, files)?;
    434     }
    435     Ok(())
    436 }
    437 
    438 fn relative_path_string(path: &Path) -> Result<String, String> {
    439     let mut parts = Vec::new();
    440     for component in path.components() {
    441         match component {
    442             Component::Normal(part) => {
    443                 parts.push(
    444                     part.to_str()
    445                         .ok_or_else(|| format!("path is not UTF-8: {}", path.display()))?
    446                         .to_owned(),
    447                 );
    448             }
    449             Component::CurDir => {}
    450             Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
    451                 return Err(format!("path is not relative: {}", path.display()));
    452             }
    453         }
    454     }
    455     Ok(parts.join("/"))
    456 }
    457 
    458 fn read_backup_manifest(path: &Path) -> Result<TenantBackupManifest, String> {
    459     let raw = fs::read_to_string(path)
    460         .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    461     serde_json::from_str(&raw).map_err(|error| format!("backup manifest JSON is invalid: {error}"))
    462 }
    463 
    464 fn read_checksum_manifest(path: &Path) -> Result<ChecksumManifest, String> {
    465     let raw = fs::read_to_string(path)
    466         .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    467     let manifest: ChecksumManifest = serde_json::from_str(&raw)
    468         .map_err(|error| format!("checksum manifest JSON is invalid: {error}"))?;
    469     if manifest.schema != CHECKSUM_SCHEMA {
    470         return Err(format!("unsupported checksum schema: {}", manifest.schema));
    471     }
    472     if manifest.algorithm != "sha256" {
    473         return Err(format!(
    474             "unsupported checksum algorithm: {}",
    475             manifest.algorithm
    476         ));
    477     }
    478     Ok(manifest)
    479 }
    480 
    481 fn verify_checksums(root: &Path, files: &[ChecksumFile]) -> Result<(), String> {
    482     for expected in files {
    483         let path = root.join(&expected.path);
    484         let (sha256, size_bytes) = file_sha256_hex(&path)?;
    485         if sha256 != expected.sha256 || size_bytes != expected.size_bytes {
    486             return Err(format!("backup checksum mismatch for {}", expected.path));
    487         }
    488     }
    489     Ok(())
    490 }
    491 
    492 #[cfg(test)]
    493 mod tests {
    494     use super::{TenantBackupRequest, TenantRestoreRequest, backup_tenant, restore_tenant};
    495     use crate::{
    496         backup::{BACKUP_MANIFEST, CHECKSUM_MANIFEST, REDACTED_TENANT_CONFIG},
    497         pocket_conversion::tangle_event_to_pocket,
    498     };
    499     use serde_json::{Value, json};
    500     use std::path::{Path, PathBuf};
    501     use tangle_protocol::Tag;
    502     use tangle_store_pocket::{PocketStoreConfig, PocketStoreHandle, PocketSyncPolicy};
    503     use tangle_test_support::{FixtureKey, tangle_v2_event};
    504 
    505     #[test]
    506     fn backup_creates_manifest_redacted_config_checksum_and_store_snapshot() {
    507         let fixture = BackupFixture::new("backup-create");
    508         fixture.write_config();
    509         fixture.store_event("alpha event", 1_714_300_001);
    510         let report = backup_tenant(TenantBackupRequest {
    511             config_path: fixture.host_config.to_str().expect("config"),
    512             tenant_id: "alpha",
    513             output: fixture.backup_dir.to_str().expect("backup"),
    514             include_secrets: false,
    515         })
    516         .expect("backup");
    517 
    518         assert_eq!(report.tenant_id, "alpha");
    519         let manifest = read_json(&fixture.backup_dir.join(BACKUP_MANIFEST));
    520         assert_eq!(manifest["schema"], "tangle.tenant.backup.v1");
    521         assert_eq!(manifest["source"]["tenant_id"], "alpha");
    522         assert_eq!(manifest["includes_secrets"], false);
    523         assert!(manifest["checksum_file_count"].as_u64().expect("count") >= 3);
    524         assert!(
    525             fixture
    526                 .backup_dir
    527                 .join("pocket_store")
    528                 .join("event.map")
    529                 .exists()
    530         );
    531         assert!(
    532             fixture
    533                 .backup_dir
    534                 .join("pocket_store")
    535                 .join("lmdb")
    536                 .join("data.mdb")
    537                 .exists()
    538         );
    539         assert!(fixture.backup_dir.join(CHECKSUM_MANIFEST).exists());
    540         let redacted = fs_read(&fixture.backup_dir.join(REDACTED_TENANT_CONFIG));
    541         assert!(redacted.contains("\"relay_secret\": \"<redacted>\""));
    542         assert!(
    543             !redacted.contains("7777777777777777777777777777777777777777777777777777777777777777")
    544         );
    545 
    546         fixture.cleanup();
    547     }
    548 
    549     #[test]
    550     fn backup_rejects_secret_inclusion_requests() {
    551         let fixture = BackupFixture::new("backup-secrets");
    552         fixture.write_config();
    553         fixture.store_event("alpha event", 1_714_300_011);
    554         let error = backup_tenant(TenantBackupRequest {
    555             config_path: fixture.host_config.to_str().expect("config"),
    556             tenant_id: "alpha",
    557             output: fixture.backup_dir.to_str().expect("backup"),
    558             include_secrets: true,
    559         })
    560         .expect_err("secrets unsupported");
    561 
    562         assert_eq!(error, "including tenant secrets in backups is unsupported");
    563 
    564         fixture.cleanup();
    565     }
    566 
    567     #[test]
    568     fn restore_verifies_checksums_and_recreates_usable_store() {
    569         let fixture = BackupFixture::new("backup-restore");
    570         fixture.write_config();
    571         fixture.store_event("alpha event", 1_714_300_021);
    572         backup_tenant(TenantBackupRequest {
    573             config_path: fixture.host_config.to_str().expect("config"),
    574             tenant_id: "alpha",
    575             output: fixture.backup_dir.to_str().expect("backup"),
    576             include_secrets: false,
    577         })
    578         .expect("backup");
    579         let report = restore_tenant(TenantRestoreRequest {
    580             config_path: fixture.host_config.to_str().expect("config"),
    581             tenant_id: "alpha",
    582             input: fixture.backup_dir.to_str().expect("backup"),
    583             target_data_dir: fixture.restore_dir.to_str().expect("restore"),
    584         })
    585         .expect("restore");
    586         let restored_config =
    587             PocketStoreConfig::new(&fixture.restore_dir, PocketSyncPolicy::FlushOnShutdown)
    588                 .expect("config");
    589         let restored = PocketStoreHandle::open(&restored_config).expect("open");
    590         let events = restored.scan_events().expect("scan");
    591 
    592         assert_eq!(report.tenant_id, "alpha");
    593         assert_eq!(events.len(), 1);
    594         assert_eq!(event_content(events[0].event()), "alpha event");
    595 
    596         fixture.cleanup();
    597     }
    598 
    599     #[test]
    600     fn restore_refuses_non_empty_targets_and_corrupt_backup_files() {
    601         let fixture = BackupFixture::new("backup-corrupt");
    602         fixture.write_config();
    603         fixture.store_event("alpha event", 1_714_300_031);
    604         backup_tenant(TenantBackupRequest {
    605             config_path: fixture.host_config.to_str().expect("config"),
    606             tenant_id: "alpha",
    607             output: fixture.backup_dir.to_str().expect("backup"),
    608             include_secrets: false,
    609         })
    610         .expect("backup");
    611         std::fs::create_dir_all(&fixture.restore_dir).expect("restore dir");
    612         std::fs::write(fixture.restore_dir.join("existing"), b"present").expect("existing");
    613         let dirty_error = restore_tenant(TenantRestoreRequest {
    614             config_path: fixture.host_config.to_str().expect("config"),
    615             tenant_id: "alpha",
    616             input: fixture.backup_dir.to_str().expect("backup"),
    617             target_data_dir: fixture.restore_dir.to_str().expect("restore"),
    618         })
    619         .expect_err("dirty target");
    620 
    621         assert!(dirty_error.contains("restore target data directory must be empty"));
    622         std::fs::remove_dir_all(&fixture.restore_dir).expect("clean target");
    623         std::fs::write(
    624             fixture.backup_dir.join("pocket_store").join("event.map"),
    625             b"corrupt",
    626         )
    627         .expect("corrupt");
    628         let corrupt_error = restore_tenant(TenantRestoreRequest {
    629             config_path: fixture.host_config.to_str().expect("config"),
    630             tenant_id: "alpha",
    631             input: fixture.backup_dir.to_str().expect("backup"),
    632             target_data_dir: fixture.restore_dir.to_str().expect("restore"),
    633         })
    634         .expect_err("corrupt");
    635 
    636         assert!(corrupt_error.contains("backup checksum mismatch"));
    637 
    638         fixture.cleanup();
    639     }
    640 
    641     struct BackupFixture {
    642         root: PathBuf,
    643         host_config: PathBuf,
    644         alpha_store: PathBuf,
    645         backup_dir: PathBuf,
    646         restore_dir: PathBuf,
    647     }
    648 
    649     impl BackupFixture {
    650         fn new(name: &str) -> Self {
    651             let root = temp_root(name);
    652             let _ = std::fs::remove_dir_all(&root);
    653             Self {
    654                 host_config: root.join("host.json"),
    655                 alpha_store: root.join("alpha-pocket"),
    656                 backup_dir: root.join("backup"),
    657                 restore_dir: root.join("restore-pocket"),
    658                 root,
    659             }
    660         }
    661 
    662         fn write_config(&self) {
    663             std::fs::create_dir_all(self.root.join("tenants")).expect("tenants");
    664             std::fs::write(
    665                 &self.host_config,
    666                 json!({
    667                     "listen_addr": "127.0.0.1:0",
    668                     "tenant_config_dir": "tenants"
    669                 })
    670                 .to_string(),
    671             )
    672             .expect("host");
    673             std::fs::write(
    674                 self.root.join("tenants").join("alpha.json"),
    675                 tenant_config_json("alpha", "alpha.test", &self.alpha_store).to_string(),
    676             )
    677             .expect("alpha tenant");
    678             std::fs::write(
    679                 self.root.join("tenants").join("beta.json"),
    680                 tenant_config_json("beta", "beta.test", &self.root.join("beta-pocket")).to_string(),
    681             )
    682             .expect("beta tenant");
    683         }
    684 
    685         fn store_event(&self, content: &str, created_at: u64) {
    686             let config =
    687                 PocketStoreConfig::new(&self.alpha_store, PocketSyncPolicy::FlushOnShutdown)
    688                     .expect("config");
    689             let handle = PocketStoreHandle::open(&config).expect("open");
    690             let event = tangle_v2_event(
    691                 FixtureKey::Member,
    692                 created_at,
    693                 1,
    694                 vec![Tag::from_parts("t", &["alpha"]).expect("tag")],
    695                 content,
    696             )
    697             .expect("event");
    698             let pocket = tangle_event_to_pocket(&event).expect("pocket");
    699             handle.store_event(&pocket).expect("store");
    700             handle.sync().expect("sync");
    701         }
    702 
    703         fn cleanup(self) {
    704             let _ = std::fs::remove_dir_all(self.root);
    705         }
    706     }
    707 
    708     fn tenant_config_json(tenant_id: &str, host: &str, store: &Path) -> Value {
    709         let relay_secret = if tenant_id == "alpha" {
    710             "7777777777777777777777777777777777777777777777777777777777777777"
    711         } else {
    712             "8888888888888888888888888888888888888888888888888888888888888888"
    713         };
    714         json!({
    715             "tenant_id": tenant_id,
    716             "tenant_schema": tenant_id,
    717             "host": host,
    718             "relay_url": format!("wss://{host}"),
    719             "info": {"name": format!("{tenant_id} relay")},
    720             "pocket": {
    721                 "data_directory": store,
    722                 "sync_policy": "flush_on_shutdown"
    723             },
    724             "pocket_query": {
    725                 "allow_scraping": false,
    726                 "allow_scrape_if_limited_to": 100,
    727                 "allow_scrape_if_max_seconds": 3600
    728             },
    729             "groups": {
    730                 "enabled": true,
    731                 "canonical_relay_url": format!("wss://{host}"),
    732                 "relay_secret": relay_secret,
    733                 "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()],
    734                 "admin_pubkeys": [FixtureKey::Admin.public_key().as_str()]
    735             },
    736             "auth": {
    737                 "challenge_ttl_seconds": 300,
    738                 "created_at_skew_seconds": 600
    739             },
    740             "limits": {
    741                 "max_message_length": 1048576,
    742                 "max_subid_length": 64,
    743                 "max_subscriptions_per_connection": 64,
    744                 "max_filters_per_request": 10,
    745                 "max_tag_values_per_filter": 100,
    746                 "max_query_complexity": 2048,
    747                 "max_limit": 500,
    748                 "default_limit": 100,
    749                 "max_event_tags": 200,
    750                 "max_content_length": 65536,
    751                 "broadcast_channel_capacity": 16,
    752                 "per_connection_outbound_queue": 16
    753             },
    754             "rate_limits": {
    755                 "auth": {
    756                     "per_ip": {"window_seconds": 60, "max_hits": 120},
    757                     "per_pubkey": {"window_seconds": 60, "max_hits": 30},
    758                     "failures": {"window_seconds": 300, "max_hits": 5},
    759                     "failures_per_ip": {"window_seconds": 300, "max_hits": 20}
    760                 },
    761                 "event": {
    762                     "per_ip": {"window_seconds": 60, "max_hits": 600},
    763                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
    764                     "per_kind": {"window_seconds": 60, "max_hits": 1000}
    765                 },
    766                 "group": {
    767                     "write_per_ip": {"window_seconds": 60, "max_hits": 300},
    768                     "write_per_pubkey": {"window_seconds": 60, "max_hits": 60},
    769                     "write_per_group": {"window_seconds": 60, "max_hits": 90},
    770                     "write_per_kind": {"window_seconds": 60, "max_hits": 300},
    771                     "join_flow": {"window_seconds": 300, "max_hits": 10},
    772                     "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30}
    773                 },
    774                 "req": {
    775                     "per_ip": {"window_seconds": 60, "max_hits": 600},
    776                     "per_connection": {"window_seconds": 60, "max_hits": 120},
    777                     "per_pubkey": {"window_seconds": 60, "max_hits": 240},
    778                     "per_group": {"window_seconds": 60, "max_hits": 240},
    779                     "per_kind": {"window_seconds": 60, "max_hits": 500},
    780                     "broad": {"window_seconds": 60, "max_hits": 30}
    781                 },
    782                 "count": {
    783                     "per_ip": {"window_seconds": 60, "max_hits": 300},
    784                     "per_connection": {"window_seconds": 60, "max_hits": 60},
    785                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
    786                     "per_group": {"window_seconds": 60, "max_hits": 120},
    787                     "per_kind": {"window_seconds": 60, "max_hits": 240},
    788                     "broad": {"window_seconds": 60, "max_hits": 20}
    789                 }
    790             },
    791             "backup_export": {
    792                 "backup_enabled": true,
    793                 "export_enabled": true
    794             }
    795         })
    796     }
    797 
    798     fn read_json(path: &Path) -> Value {
    799         serde_json::from_str(&fs_read(path)).expect("json")
    800     }
    801 
    802     fn fs_read(path: &Path) -> String {
    803         std::fs::read_to_string(path).expect("read")
    804     }
    805 
    806     fn event_content(event: &tangle_store_pocket::PocketEvent) -> String {
    807         std::str::from_utf8(event.content())
    808             .expect("utf8")
    809             .to_owned()
    810     }
    811 
    812     fn temp_root(name: &str) -> PathBuf {
    813         std::env::temp_dir().join(format!("tangle-runtime-{name}-{}", std::process::id()))
    814     }
    815 }