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 }