portable_backup.rs (27789B)
1 #![forbid(unsafe_code)] 2 3 use crate::TANGLE_RELAY_VERSION; 4 use serde::{Deserialize, Serialize}; 5 use sha2::{Digest, Sha256}; 6 use std::{ 7 collections::BTreeSet, 8 fs::{self, File}, 9 io::{BufRead, BufReader, BufWriter, Read, Write}, 10 path::{Component, Path, PathBuf}, 11 str, 12 time::{SystemTime, UNIX_EPOCH}, 13 }; 14 use tangle_store_pocket::{ 15 POCKET_SOURCE_REVISION, PocketEvent, PocketStoreConfig, PocketStoreHandle, PocketSyncPolicy, 16 parse_pocket_event_json, 17 }; 18 19 const BACKUP_SCHEMA: &str = "tangle.portable-relay-backup.v1"; 20 const MANIFEST_FILE: &str = "manifest.json"; 21 const EVENTS_FILE: &str = "events.jsonl"; 22 23 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 24 pub struct PortableRelayBackupPolicy { 25 maximum_event_count: u64, 26 maximum_event_json_bytes: u64, 27 maximum_backup_bytes: u64, 28 } 29 30 impl PortableRelayBackupPolicy { 31 pub fn new( 32 maximum_event_count: u64, 33 maximum_event_json_bytes: u64, 34 maximum_backup_bytes: u64, 35 ) -> Result<Self, String> { 36 if maximum_event_count == 0 37 || maximum_event_json_bytes == 0 38 || maximum_backup_bytes == 0 39 || maximum_event_json_bytes > maximum_backup_bytes 40 { 41 return Err("portable relay backup limits must be nonzero and ordered".to_owned()); 42 } 43 Ok(Self { 44 maximum_event_count, 45 maximum_event_json_bytes, 46 maximum_backup_bytes, 47 }) 48 } 49 50 pub fn maximum_event_count(self) -> u64 { 51 self.maximum_event_count 52 } 53 54 pub fn maximum_event_json_bytes(self) -> u64 { 55 self.maximum_event_json_bytes 56 } 57 58 pub fn maximum_backup_bytes(self) -> u64 { 59 self.maximum_backup_bytes 60 } 61 } 62 63 #[derive(Debug, Clone, PartialEq, Eq)] 64 pub struct PortableRelayBackupIdentity { 65 relay_url: String, 66 } 67 68 impl PortableRelayBackupIdentity { 69 pub fn new(relay_url: impl Into<String>) -> Result<Self, String> { 70 let relay_url = relay_url.into(); 71 if !(relay_url.starts_with("ws://") || relay_url.starts_with("wss://")) 72 || relay_url.chars().any(char::is_whitespace) 73 { 74 return Err("portable relay backup identity requires a relay URL".to_owned()); 75 } 76 Ok(Self { relay_url }) 77 } 78 79 pub fn relay_url(&self) -> &str { 80 &self.relay_url 81 } 82 } 83 84 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 85 pub struct PortableRelayBackupReport { 86 pub backup_path: String, 87 pub relay_url: String, 88 pub created_at_unix_seconds: u64, 89 pub event_count: u64, 90 pub events_sha256: String, 91 pub events_size_bytes: u64, 92 pub first_store_offset: Option<u64>, 93 pub last_store_offset: Option<u64>, 94 } 95 96 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 97 pub struct PortableRelayRestoreReport { 98 pub backup_path: String, 99 pub target_data_directory: String, 100 pub event_count: u64, 101 pub events_sha256: String, 102 } 103 104 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 105 #[serde(deny_unknown_fields)] 106 struct PortableRelayBackupManifest { 107 schema: String, 108 tangle_version: String, 109 pocket_source_revision: String, 110 relay_url: String, 111 created_at_unix_seconds: u64, 112 events: PortableRelayBackupEvents, 113 } 114 115 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 116 #[serde(deny_unknown_fields)] 117 struct PortableRelayBackupEvents { 118 path: String, 119 count: u64, 120 sha256: String, 121 size_bytes: u64, 122 first_store_offset: Option<u64>, 123 last_store_offset: Option<u64>, 124 } 125 126 pub fn create_portable_relay_backup( 127 store_config: &PocketStoreConfig, 128 identity: &PortableRelayBackupIdentity, 129 output: &Path, 130 policy: PortableRelayBackupPolicy, 131 ) -> Result<PortableRelayBackupReport, String> { 132 validate_final_backup_path(output)?; 133 if output.exists() { 134 return Err(format!( 135 "portable relay backup already exists: {}", 136 output.display() 137 )); 138 } 139 let parent = output 140 .parent() 141 .filter(|parent| !parent.as_os_str().is_empty()) 142 .ok_or_else(|| "portable relay backup requires a parent directory".to_owned())?; 143 fs::create_dir_all(parent) 144 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; 145 let stage = staging_path(output)?; 146 if stage.exists() { 147 return Err(format!( 148 "portable relay backup stage exists: {}", 149 stage.display() 150 )); 151 } 152 fs::create_dir(&stage) 153 .map_err(|error| format!("failed to create {}: {error}", stage.display()))?; 154 let result = 155 create_backup_in_stage(store_config, identity, &stage, policy).and_then(|report| { 156 fs::rename(&stage, output).map_err(|error| { 157 format!( 158 "failed to publish portable relay backup {}: {error}", 159 output.display() 160 ) 161 })?; 162 Ok(PortableRelayBackupReport { 163 backup_path: output.display().to_string(), 164 ..report 165 }) 166 }); 167 if result.is_err() { 168 let _ = fs::remove_dir_all(&stage); 169 } 170 result 171 } 172 173 pub fn verify_portable_relay_backup( 174 input: &Path, 175 expected_identity: &PortableRelayBackupIdentity, 176 policy: PortableRelayBackupPolicy, 177 ) -> Result<PortableRelayBackupReport, String> { 178 let manifest = read_manifest(input)?; 179 if manifest.schema != BACKUP_SCHEMA { 180 return Err(format!( 181 "unsupported portable relay backup schema: {}", 182 manifest.schema 183 )); 184 } 185 if manifest.relay_url != expected_identity.relay_url() { 186 return Err("portable relay backup relay identity does not match".to_owned()); 187 } 188 if !is_lower_hex(&manifest.pocket_source_revision, 40) { 189 return Err("portable relay backup Pocket source revision is invalid".to_owned()); 190 } 191 if manifest.events.path != EVENTS_FILE { 192 return Err("portable relay backup event path is invalid".to_owned()); 193 } 194 if manifest.events.count > policy.maximum_event_count() 195 || manifest.events.size_bytes > policy.maximum_backup_bytes() 196 { 197 return Err("portable relay backup exceeds configured limits".to_owned()); 198 } 199 let events_path = input.join(EVENTS_FILE); 200 let metadata = regular_file_metadata(&events_path)?; 201 if metadata.len() != manifest.events.size_bytes { 202 return Err("portable relay backup event size does not match manifest".to_owned()); 203 } 204 let (sha256, size_bytes) = file_sha256(&events_path)?; 205 if sha256 != manifest.events.sha256 { 206 return Err("portable relay backup event checksum does not match manifest".to_owned()); 207 } 208 let observed = verify_event_lines(&events_path, policy)?; 209 if observed.count != manifest.events.count 210 || observed.first_store_offset != manifest.events.first_store_offset 211 || observed.last_store_offset != manifest.events.last_store_offset 212 { 213 return Err("portable relay backup event inventory does not match manifest".to_owned()); 214 } 215 Ok(PortableRelayBackupReport { 216 backup_path: input.display().to_string(), 217 relay_url: manifest.relay_url, 218 created_at_unix_seconds: manifest.created_at_unix_seconds, 219 event_count: observed.count, 220 events_sha256: sha256, 221 events_size_bytes: size_bytes, 222 first_store_offset: observed.first_store_offset, 223 last_store_offset: observed.last_store_offset, 224 }) 225 } 226 227 pub fn restore_portable_relay_backup( 228 input: &Path, 229 expected_identity: &PortableRelayBackupIdentity, 230 target_data_directory: &Path, 231 policy: PortableRelayBackupPolicy, 232 ) -> Result<PortableRelayRestoreReport, String> { 233 let verified = verify_portable_relay_backup(input, expected_identity, policy)?; 234 validate_final_backup_path(target_data_directory)?; 235 if target_data_directory.exists() { 236 return Err(format!( 237 "portable relay restore target already exists: {}", 238 target_data_directory.display() 239 )); 240 } 241 let parent = target_data_directory 242 .parent() 243 .filter(|parent| !parent.as_os_str().is_empty()) 244 .ok_or_else(|| "portable relay restore target requires a parent directory".to_owned())?; 245 fs::create_dir_all(parent) 246 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; 247 let stage = staging_path(target_data_directory)?; 248 fs::create_dir(&stage) 249 .map_err(|error| format!("failed to create {}: {error}", stage.display()))?; 250 let result = restore_into_stage(input, &stage, policy).and_then(|event_count| { 251 if event_count != verified.event_count { 252 return Err("portable relay restore event count differs".to_owned()); 253 } 254 fs::rename(&stage, target_data_directory).map_err(|error| { 255 format!( 256 "failed to publish portable relay restore {}: {error}", 257 target_data_directory.display() 258 ) 259 })?; 260 Ok(PortableRelayRestoreReport { 261 backup_path: input.display().to_string(), 262 target_data_directory: target_data_directory.display().to_string(), 263 event_count, 264 events_sha256: verified.events_sha256, 265 }) 266 }); 267 if result.is_err() { 268 let _ = fs::remove_dir_all(&stage); 269 } 270 result 271 } 272 273 fn create_backup_in_stage( 274 store_config: &PocketStoreConfig, 275 identity: &PortableRelayBackupIdentity, 276 stage: &Path, 277 policy: PortableRelayBackupPolicy, 278 ) -> Result<PortableRelayBackupReport, String> { 279 let store = PocketStoreHandle::open(store_config).map_err(|error| error.to_string())?; 280 store.sync().map_err(|error| error.to_string())?; 281 let events = store.scan_events().map_err(|error| error.to_string())?; 282 let event_count = u64::try_from(events.len()).expect("event count fits u64"); 283 if event_count > policy.maximum_event_count() { 284 return Err("portable relay backup event count exceeds policy".to_owned()); 285 } 286 let events_path = stage.join(EVENTS_FILE); 287 let mut writer = BufWriter::new( 288 File::create(&events_path) 289 .map_err(|error| format!("failed to create {}: {error}", events_path.display()))?, 290 ); 291 for stored in &events { 292 let line = serde_json::to_vec(&serde_json::json!({ 293 "store_offset": stored.store_offset(), 294 "event": pocket_event_json(stored.event())?, 295 })) 296 .map_err(|error| error.to_string())?; 297 if u64::try_from(line.len()).expect("line length fits u64") 298 > policy.maximum_event_json_bytes() 299 { 300 return Err("portable relay backup event JSON exceeds policy".to_owned()); 301 } 302 writer 303 .write_all(&line) 304 .and_then(|_| writer.write_all(b"\n")) 305 .map_err(|error| format!("failed to write {}: {error}", events_path.display()))?; 306 } 307 writer 308 .flush() 309 .map_err(|error| format!("failed to flush {}: {error}", events_path.display()))?; 310 writer 311 .get_ref() 312 .sync_all() 313 .map_err(|error| format!("failed to sync {}: {error}", events_path.display()))?; 314 drop(writer); 315 let (events_sha256, events_size_bytes) = file_sha256(&events_path)?; 316 if events_size_bytes > policy.maximum_backup_bytes() { 317 return Err("portable relay backup byte size exceeds policy".to_owned()); 318 } 319 let created_at_unix_seconds = now_unix_seconds()?; 320 let first_store_offset = events.first().map(|event| event.store_offset()); 321 let last_store_offset = events.last().map(|event| event.store_offset()); 322 let manifest = PortableRelayBackupManifest { 323 schema: BACKUP_SCHEMA.to_owned(), 324 tangle_version: TANGLE_RELAY_VERSION.to_owned(), 325 pocket_source_revision: POCKET_SOURCE_REVISION.to_owned(), 326 relay_url: identity.relay_url().to_owned(), 327 created_at_unix_seconds, 328 events: PortableRelayBackupEvents { 329 path: EVENTS_FILE.to_owned(), 330 count: event_count, 331 sha256: events_sha256.clone(), 332 size_bytes: events_size_bytes, 333 first_store_offset, 334 last_store_offset, 335 }, 336 }; 337 let manifest_path = stage.join(MANIFEST_FILE); 338 let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|error| error.to_string())?; 339 let mut manifest_file = File::create(&manifest_path) 340 .map_err(|error| format!("failed to create {}: {error}", manifest_path.display()))?; 341 manifest_file 342 .write_all(&manifest_bytes) 343 .and_then(|_| manifest_file.sync_all()) 344 .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?; 345 verify_portable_relay_backup(stage, identity, policy)?; 346 Ok(PortableRelayBackupReport { 347 backup_path: stage.display().to_string(), 348 relay_url: identity.relay_url().to_owned(), 349 created_at_unix_seconds, 350 event_count, 351 events_sha256, 352 events_size_bytes, 353 first_store_offset, 354 last_store_offset, 355 }) 356 } 357 358 fn restore_into_stage( 359 input: &Path, 360 stage: &Path, 361 policy: PortableRelayBackupPolicy, 362 ) -> Result<u64, String> { 363 let config = PocketStoreConfig::new(stage, PocketSyncPolicy::FlushOnWrite) 364 .map_err(|error| error.to_string())?; 365 let store = PocketStoreHandle::open(&config).map_err(|error| error.to_string())?; 366 let events_path = input.join(EVENTS_FILE); 367 let mut reader = BufReader::new( 368 File::open(&events_path) 369 .map_err(|error| format!("failed to open {}: {error}", events_path.display()))?, 370 ); 371 let mut line = Vec::new(); 372 let mut count = 0_u64; 373 loop { 374 line.clear(); 375 if reader 376 .read_until(b'\n', &mut line) 377 .map_err(|error| format!("failed to read {}: {error}", events_path.display()))? 378 == 0 379 { 380 break; 381 } 382 let parsed = parse_backup_line(&line, policy)?; 383 store 384 .store_event(&parsed.event) 385 .map_err(|error| error.to_string())?; 386 count = count 387 .checked_add(1) 388 .ok_or_else(|| "portable relay restore event count overflowed".to_owned())?; 389 } 390 store.sync().map_err(|error| error.to_string())?; 391 let restored = store.scan_events().map_err(|error| error.to_string())?; 392 if u64::try_from(restored.len()).expect("event count fits u64") != count { 393 return Err("portable relay restore verification count differs".to_owned()); 394 } 395 Ok(count) 396 } 397 398 struct VerifiedEventInventory { 399 count: u64, 400 first_store_offset: Option<u64>, 401 last_store_offset: Option<u64>, 402 } 403 404 fn verify_event_lines( 405 path: &Path, 406 policy: PortableRelayBackupPolicy, 407 ) -> Result<VerifiedEventInventory, String> { 408 let mut reader = BufReader::new( 409 File::open(path).map_err(|error| format!("failed to open {}: {error}", path.display()))?, 410 ); 411 let mut line = Vec::new(); 412 let mut count = 0_u64; 413 let mut first_store_offset = None; 414 let mut last_store_offset = None; 415 let mut event_ids = BTreeSet::new(); 416 loop { 417 line.clear(); 418 if reader 419 .read_until(b'\n', &mut line) 420 .map_err(|error| format!("failed to read {}: {error}", path.display()))? 421 == 0 422 { 423 break; 424 } 425 let parsed = parse_backup_line(&line, policy)?; 426 parsed 427 .event 428 .verify() 429 .map_err(|_| "portable relay backup contains an invalid event signature".to_owned())?; 430 if last_store_offset.is_some_and(|offset| parsed.store_offset <= offset) { 431 return Err( 432 "portable relay backup store offsets are not strictly increasing".to_owned(), 433 ); 434 } 435 if !event_ids.insert(parsed.event.id().as_hex_string()) { 436 return Err("portable relay backup contains duplicate event IDs".to_owned()); 437 } 438 first_store_offset.get_or_insert(parsed.store_offset); 439 last_store_offset = Some(parsed.store_offset); 440 count = count 441 .checked_add(1) 442 .ok_or_else(|| "portable relay backup event count overflowed".to_owned())?; 443 if count > policy.maximum_event_count() { 444 return Err("portable relay backup event count exceeds policy".to_owned()); 445 } 446 } 447 Ok(VerifiedEventInventory { 448 count, 449 first_store_offset, 450 last_store_offset, 451 }) 452 } 453 454 struct ParsedBackupLine { 455 store_offset: u64, 456 event: tangle_store_pocket::PocketOwnedEvent, 457 } 458 459 #[derive(Deserialize)] 460 #[serde(deny_unknown_fields)] 461 struct BackupLineDocument { 462 store_offset: u64, 463 event: serde_json::Value, 464 } 465 466 fn parse_backup_line( 467 line: &[u8], 468 policy: PortableRelayBackupPolicy, 469 ) -> Result<ParsedBackupLine, String> { 470 let line = line.strip_suffix(b"\n").unwrap_or(line); 471 if line.is_empty() 472 || u64::try_from(line.len()).expect("line length fits u64") 473 > policy.maximum_event_json_bytes() 474 { 475 return Err("portable relay backup event line is empty or oversized".to_owned()); 476 } 477 let value: BackupLineDocument = serde_json::from_slice(line) 478 .map_err(|_| "portable relay backup line is invalid".to_owned())?; 479 let raw = serde_json::to_vec(&value.event).map_err(|error| error.to_string())?; 480 let event = parse_pocket_event_json(&raw).map_err(|error| error.to_string())?; 481 Ok(ParsedBackupLine { 482 store_offset: value.store_offset, 483 event, 484 }) 485 } 486 487 fn pocket_event_json(event: &PocketEvent) -> Result<serde_json::Value, String> { 488 let tags = event 489 .tags() 490 .map_err(|error| error.to_string())? 491 .iter() 492 .map(|tag| { 493 tag.map(|value| { 494 str::from_utf8(value) 495 .map(str::to_owned) 496 .map_err(|error| error.to_string()) 497 }) 498 .collect::<Result<Vec<_>, _>>() 499 }) 500 .collect::<Result<Vec<_>, _>>()?; 501 let content = str::from_utf8(event.content()).map_err(|error| error.to_string())?; 502 Ok(serde_json::json!({ 503 "id": event.id().as_hex_string(), 504 "pubkey": event.pubkey().as_hex_string(), 505 "created_at": event.created_at().as_u64(), 506 "kind": event.kind().as_u16(), 507 "tags": tags, 508 "content": content, 509 "sig": event.sig().to_string(), 510 })) 511 } 512 513 fn read_manifest(input: &Path) -> Result<PortableRelayBackupManifest, String> { 514 let metadata = fs::symlink_metadata(input) 515 .map_err(|error| format!("failed to stat {}: {error}", input.display()))?; 516 if metadata.file_type().is_symlink() || !metadata.is_dir() { 517 return Err("portable relay backup input must be a real directory".to_owned()); 518 } 519 let path = input.join(MANIFEST_FILE); 520 let metadata = regular_file_metadata(&path)?; 521 if metadata.len() > 64 * 1024 { 522 return Err("portable relay backup manifest exceeds size policy".to_owned()); 523 } 524 let raw = 525 fs::read(&path).map_err(|error| format!("failed to read {}: {error}", path.display()))?; 526 serde_json::from_slice(&raw).map_err(|_| "portable relay backup manifest is invalid".to_owned()) 527 } 528 529 fn regular_file_metadata(path: &Path) -> Result<fs::Metadata, String> { 530 let metadata = fs::symlink_metadata(path) 531 .map_err(|error| format!("failed to stat {}: {error}", path.display()))?; 532 if metadata.file_type().is_symlink() || !metadata.is_file() { 533 return Err(format!( 534 "portable relay backup file is unsafe: {}", 535 path.display() 536 )); 537 } 538 Ok(metadata) 539 } 540 541 fn file_sha256(path: &Path) -> Result<(String, u64), String> { 542 let mut file = 543 File::open(path).map_err(|error| format!("failed to open {}: {error}", path.display()))?; 544 let mut digest = Sha256::new(); 545 let mut size = 0_u64; 546 let mut buffer = [0_u8; 64 * 1024]; 547 loop { 548 let read = file 549 .read(&mut buffer) 550 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 551 if read == 0 { 552 break; 553 } 554 digest.update(&buffer[..read]); 555 size = size 556 .checked_add(u64::try_from(read).expect("read length fits u64")) 557 .ok_or_else(|| "portable relay backup size overflowed".to_owned())?; 558 } 559 Ok((lower_hex(&digest.finalize()), size)) 560 } 561 562 fn validate_final_backup_path(path: &Path) -> Result<(), String> { 563 if path.as_os_str().is_empty() { 564 return Err("portable relay backup path must not be empty".to_owned()); 565 } 566 for component in path.components() { 567 if matches!(component, Component::ParentDir) { 568 return Err("portable relay backup path must not contain parent traversal".to_owned()); 569 } 570 } 571 Ok(()) 572 } 573 574 fn staging_path(path: &Path) -> Result<PathBuf, String> { 575 let name = path 576 .file_name() 577 .and_then(|name| name.to_str()) 578 .ok_or_else(|| "portable relay backup path requires a UTF-8 file name".to_owned())?; 579 let nonce = SystemTime::now() 580 .duration_since(UNIX_EPOCH) 581 .map_err(|error| error.to_string())? 582 .as_nanos(); 583 Ok(path.with_file_name(format!(".{name}.stage-{}-{nonce}", std::process::id()))) 584 } 585 586 fn now_unix_seconds() -> Result<u64, String> { 587 SystemTime::now() 588 .duration_since(UNIX_EPOCH) 589 .map(|duration| duration.as_secs()) 590 .map_err(|error| error.to_string()) 591 } 592 593 fn lower_hex(bytes: &[u8]) -> String { 594 const HEX: &[u8; 16] = b"0123456789abcdef"; 595 let mut output = String::with_capacity(bytes.len() * 2); 596 for byte in bytes { 597 output.push(char::from(HEX[usize::from(byte >> 4)])); 598 output.push(char::from(HEX[usize::from(byte & 0x0f)])); 599 } 600 output 601 } 602 603 fn is_lower_hex(value: &str, expected: usize) -> bool { 604 value.len() == expected 605 && value 606 .bytes() 607 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 608 } 609 610 #[cfg(test)] 611 mod tests { 612 use super::{ 613 PortableRelayBackupIdentity, PortableRelayBackupPolicy, create_portable_relay_backup, 614 restore_portable_relay_backup, verify_portable_relay_backup, 615 }; 616 use crate::pocket_conversion::tangle_event_to_pocket; 617 use std::path::PathBuf; 618 use tangle_store_pocket::{ 619 PocketStoreConfig, PocketStoreHandle, PocketSyncPolicy, parse_pocket_filter_json, 620 }; 621 use tangle_test_support::{FixtureKey, build_fixture_event_from_parts}; 622 623 #[test] 624 fn portable_backup_round_trip_preserves_profile_head_and_delete_event() { 625 let root = temp_root("round-trip"); 626 let _ = std::fs::remove_dir_all(&root); 627 let source = root.join("source"); 628 let backup = root.join("backups").join("backup-1"); 629 let restored = root.join("restored"); 630 let source_config = 631 PocketStoreConfig::new(&source, PocketSyncPolicy::FlushOnWrite).expect("source config"); 632 let source_store = PocketStoreHandle::open(&source_config).expect("source store"); 633 for event in [ 634 event(1_714_124_433, 0, Vec::new(), r#"{"name":"Farm"}"#), 635 event(1_714_124_434, 0, Vec::new(), r#"{"name":"Farm Market"}"#), 636 event( 637 1_714_124_435, 638 5, 639 vec![vec!["e".to_owned(), "0".repeat(64)]], 640 "deleted", 641 ), 642 ] { 643 source_store 644 .store_event(&tangle_event_to_pocket(&event).expect("pocket event")) 645 .expect("store event"); 646 } 647 let identity = 648 PortableRelayBackupIdentity::new("wss://relay.radroots.test").expect("identity"); 649 let policy = policy(); 650 651 let created = create_portable_relay_backup(&source_config, &identity, &backup, policy) 652 .expect("backup"); 653 let verified = 654 verify_portable_relay_backup(&backup, &identity, policy).expect("verify backup"); 655 let restore = 656 restore_portable_relay_backup(&backup, &identity, &restored, policy).expect("restore"); 657 658 assert_eq!(created.event_count, 3); 659 assert_eq!(verified.event_count, 3); 660 assert_eq!(restore.event_count, 3); 661 assert_eq!(created.events_sha256, restore.events_sha256); 662 let restored_config = PocketStoreConfig::new(&restored, PocketSyncPolicy::FlushOnWrite) 663 .expect("restored config"); 664 let restored_store = PocketStoreHandle::open(&restored_config).expect("restored store"); 665 let profile_filter = parse_pocket_filter_json( 666 format!( 667 r#"{{"authors":["{}"],"kinds":[0]}}"#, 668 FixtureKey::Member.public_key() 669 ) 670 .as_bytes(), 671 ) 672 .expect("profile filter"); 673 let profile = restored_store 674 .find_events( 675 &profile_filter, 676 tangle_store_pocket::PocketQueryConfig::default(), 677 ) 678 .expect("profile query"); 679 assert_eq!(profile.len(), 1); 680 assert_eq!(profile[0].content(), br#"{"name":"Farm Market"}"#); 681 let delete_filter = parse_pocket_filter_json( 682 format!( 683 r#"{{"authors":["{}"],"kinds":[5]}}"#, 684 FixtureKey::Member.public_key() 685 ) 686 .as_bytes(), 687 ) 688 .expect("delete filter"); 689 assert_eq!( 690 restored_store 691 .find_events( 692 &delete_filter, 693 tangle_store_pocket::PocketQueryConfig::default() 694 ) 695 .expect("delete query") 696 .len(), 697 1 698 ); 699 let _ = std::fs::remove_dir_all(root); 700 } 701 702 #[test] 703 fn portable_backup_detects_corruption_identity_drift_and_existing_restore_target() { 704 let root = temp_root("adverse"); 705 let _ = std::fs::remove_dir_all(&root); 706 let source_config = 707 PocketStoreConfig::new(root.join("source"), PocketSyncPolicy::FlushOnWrite) 708 .expect("source config"); 709 let source = PocketStoreHandle::open(&source_config).expect("source"); 710 source 711 .store_event( 712 &tangle_event_to_pocket(&event(1_714_124_433, 1, Vec::new(), "note")) 713 .expect("pocket"), 714 ) 715 .expect("store"); 716 let identity = 717 PortableRelayBackupIdentity::new("wss://relay.radroots.test").expect("identity"); 718 let backup = root.join("backup"); 719 create_portable_relay_backup(&source_config, &identity, &backup, policy()).expect("backup"); 720 assert!( 721 verify_portable_relay_backup( 722 &backup, 723 &PortableRelayBackupIdentity::new("wss://other.test").expect("other"), 724 policy(), 725 ) 726 .is_err() 727 ); 728 let target = root.join("existing"); 729 std::fs::create_dir_all(&target).expect("target"); 730 assert!(restore_portable_relay_backup(&backup, &identity, &target, policy()).is_err()); 731 std::fs::write(backup.join("events.jsonl"), b"corrupt\n").expect("corrupt"); 732 assert!(verify_portable_relay_backup(&backup, &identity, policy()).is_err()); 733 let _ = std::fs::remove_dir_all(root); 734 } 735 736 fn event( 737 created_at: u64, 738 kind: u64, 739 tags: Vec<Vec<String>>, 740 content: &str, 741 ) -> tangle_protocol::Event { 742 build_fixture_event_from_parts(FixtureKey::Member, created_at, kind, tags, content) 743 .expect("event") 744 } 745 746 fn policy() -> PortableRelayBackupPolicy { 747 PortableRelayBackupPolicy::new(100, 128 * 1024, 1024 * 1024).expect("policy") 748 } 749 750 fn temp_root(name: &str) -> PathBuf { 751 std::env::temp_dir().join(format!( 752 "tangle-portable-backup-{name}-{}", 753 std::process::id() 754 )) 755 } 756 }