tangle


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

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 }