commit 78e63ecd7dd46ba35c268626ef0d50bfb4759443
parent 4be0ea979861c587146a42ed619b2551c4e131f7
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 02:37:21 +0000
runtime: add portable relay backup restore
Diffstat:
2 files changed, 757 insertions(+), 0 deletions(-)
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -13,6 +13,7 @@ pub mod nip11;
pub mod ops;
pub(crate) mod pocket_conversion;
pub(crate) mod pocket_event_validation;
+pub mod portable_backup;
pub mod rate_limits;
pub mod relay;
pub mod resource_limits;
diff --git a/crates/tangle_runtime/src/portable_backup.rs b/crates/tangle_runtime/src/portable_backup.rs
@@ -0,0 +1,756 @@
+#![forbid(unsafe_code)]
+
+use crate::TANGLE_RELAY_VERSION;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::{
+ collections::BTreeSet,
+ fs::{self, File},
+ io::{BufRead, BufReader, BufWriter, Read, Write},
+ path::{Component, Path, PathBuf},
+ str,
+ time::{SystemTime, UNIX_EPOCH},
+};
+use tangle_store_pocket::{
+ POCKET_SOURCE_REVISION, PocketEvent, PocketStoreConfig, PocketStoreHandle, PocketSyncPolicy,
+ parse_pocket_event_json,
+};
+
+const BACKUP_SCHEMA: &str = "tangle.portable-relay-backup.v1";
+const MANIFEST_FILE: &str = "manifest.json";
+const EVENTS_FILE: &str = "events.jsonl";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PortableRelayBackupPolicy {
+ maximum_event_count: u64,
+ maximum_event_json_bytes: u64,
+ maximum_backup_bytes: u64,
+}
+
+impl PortableRelayBackupPolicy {
+ pub fn new(
+ maximum_event_count: u64,
+ maximum_event_json_bytes: u64,
+ maximum_backup_bytes: u64,
+ ) -> Result<Self, String> {
+ if maximum_event_count == 0
+ || maximum_event_json_bytes == 0
+ || maximum_backup_bytes == 0
+ || maximum_event_json_bytes > maximum_backup_bytes
+ {
+ return Err("portable relay backup limits must be nonzero and ordered".to_owned());
+ }
+ Ok(Self {
+ maximum_event_count,
+ maximum_event_json_bytes,
+ maximum_backup_bytes,
+ })
+ }
+
+ pub fn maximum_event_count(self) -> u64 {
+ self.maximum_event_count
+ }
+
+ pub fn maximum_event_json_bytes(self) -> u64 {
+ self.maximum_event_json_bytes
+ }
+
+ pub fn maximum_backup_bytes(self) -> u64 {
+ self.maximum_backup_bytes
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PortableRelayBackupIdentity {
+ relay_url: String,
+}
+
+impl PortableRelayBackupIdentity {
+ pub fn new(relay_url: impl Into<String>) -> Result<Self, String> {
+ let relay_url = relay_url.into();
+ if !(relay_url.starts_with("ws://") || relay_url.starts_with("wss://"))
+ || relay_url.chars().any(char::is_whitespace)
+ {
+ return Err("portable relay backup identity requires a relay URL".to_owned());
+ }
+ Ok(Self { relay_url })
+ }
+
+ pub fn relay_url(&self) -> &str {
+ &self.relay_url
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PortableRelayBackupReport {
+ pub backup_path: String,
+ pub relay_url: String,
+ pub created_at_unix_seconds: u64,
+ pub event_count: u64,
+ pub events_sha256: String,
+ pub events_size_bytes: u64,
+ pub first_store_offset: Option<u64>,
+ pub last_store_offset: Option<u64>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PortableRelayRestoreReport {
+ pub backup_path: String,
+ pub target_data_directory: String,
+ pub event_count: u64,
+ pub events_sha256: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PortableRelayBackupManifest {
+ schema: String,
+ tangle_version: String,
+ pocket_source_revision: String,
+ relay_url: String,
+ created_at_unix_seconds: u64,
+ events: PortableRelayBackupEvents,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PortableRelayBackupEvents {
+ path: String,
+ count: u64,
+ sha256: String,
+ size_bytes: u64,
+ first_store_offset: Option<u64>,
+ last_store_offset: Option<u64>,
+}
+
+pub fn create_portable_relay_backup(
+ store_config: &PocketStoreConfig,
+ identity: &PortableRelayBackupIdentity,
+ output: &Path,
+ policy: PortableRelayBackupPolicy,
+) -> Result<PortableRelayBackupReport, String> {
+ validate_final_backup_path(output)?;
+ if output.exists() {
+ return Err(format!(
+ "portable relay backup already exists: {}",
+ output.display()
+ ));
+ }
+ let parent = output
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .ok_or_else(|| "portable relay backup requires a parent directory".to_owned())?;
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
+ let stage = staging_path(output)?;
+ if stage.exists() {
+ return Err(format!(
+ "portable relay backup stage exists: {}",
+ stage.display()
+ ));
+ }
+ fs::create_dir(&stage)
+ .map_err(|error| format!("failed to create {}: {error}", stage.display()))?;
+ let result =
+ create_backup_in_stage(store_config, identity, &stage, policy).and_then(|report| {
+ fs::rename(&stage, output).map_err(|error| {
+ format!(
+ "failed to publish portable relay backup {}: {error}",
+ output.display()
+ )
+ })?;
+ Ok(PortableRelayBackupReport {
+ backup_path: output.display().to_string(),
+ ..report
+ })
+ });
+ if result.is_err() {
+ let _ = fs::remove_dir_all(&stage);
+ }
+ result
+}
+
+pub fn verify_portable_relay_backup(
+ input: &Path,
+ expected_identity: &PortableRelayBackupIdentity,
+ policy: PortableRelayBackupPolicy,
+) -> Result<PortableRelayBackupReport, String> {
+ let manifest = read_manifest(input)?;
+ if manifest.schema != BACKUP_SCHEMA {
+ return Err(format!(
+ "unsupported portable relay backup schema: {}",
+ manifest.schema
+ ));
+ }
+ if manifest.relay_url != expected_identity.relay_url() {
+ return Err("portable relay backup relay identity does not match".to_owned());
+ }
+ if !is_lower_hex(&manifest.pocket_source_revision, 40) {
+ return Err("portable relay backup Pocket source revision is invalid".to_owned());
+ }
+ if manifest.events.path != EVENTS_FILE {
+ return Err("portable relay backup event path is invalid".to_owned());
+ }
+ if manifest.events.count > policy.maximum_event_count()
+ || manifest.events.size_bytes > policy.maximum_backup_bytes()
+ {
+ return Err("portable relay backup exceeds configured limits".to_owned());
+ }
+ let events_path = input.join(EVENTS_FILE);
+ let metadata = regular_file_metadata(&events_path)?;
+ if metadata.len() != manifest.events.size_bytes {
+ return Err("portable relay backup event size does not match manifest".to_owned());
+ }
+ let (sha256, size_bytes) = file_sha256(&events_path)?;
+ if sha256 != manifest.events.sha256 {
+ return Err("portable relay backup event checksum does not match manifest".to_owned());
+ }
+ let observed = verify_event_lines(&events_path, policy)?;
+ if observed.count != manifest.events.count
+ || observed.first_store_offset != manifest.events.first_store_offset
+ || observed.last_store_offset != manifest.events.last_store_offset
+ {
+ return Err("portable relay backup event inventory does not match manifest".to_owned());
+ }
+ Ok(PortableRelayBackupReport {
+ backup_path: input.display().to_string(),
+ relay_url: manifest.relay_url,
+ created_at_unix_seconds: manifest.created_at_unix_seconds,
+ event_count: observed.count,
+ events_sha256: sha256,
+ events_size_bytes: size_bytes,
+ first_store_offset: observed.first_store_offset,
+ last_store_offset: observed.last_store_offset,
+ })
+}
+
+pub fn restore_portable_relay_backup(
+ input: &Path,
+ expected_identity: &PortableRelayBackupIdentity,
+ target_data_directory: &Path,
+ policy: PortableRelayBackupPolicy,
+) -> Result<PortableRelayRestoreReport, String> {
+ let verified = verify_portable_relay_backup(input, expected_identity, policy)?;
+ validate_final_backup_path(target_data_directory)?;
+ if target_data_directory.exists() {
+ return Err(format!(
+ "portable relay restore target already exists: {}",
+ target_data_directory.display()
+ ));
+ }
+ let parent = target_data_directory
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .ok_or_else(|| "portable relay restore target requires a parent directory".to_owned())?;
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
+ let stage = staging_path(target_data_directory)?;
+ fs::create_dir(&stage)
+ .map_err(|error| format!("failed to create {}: {error}", stage.display()))?;
+ let result = restore_into_stage(input, &stage, policy).and_then(|event_count| {
+ if event_count != verified.event_count {
+ return Err("portable relay restore event count differs".to_owned());
+ }
+ fs::rename(&stage, target_data_directory).map_err(|error| {
+ format!(
+ "failed to publish portable relay restore {}: {error}",
+ target_data_directory.display()
+ )
+ })?;
+ Ok(PortableRelayRestoreReport {
+ backup_path: input.display().to_string(),
+ target_data_directory: target_data_directory.display().to_string(),
+ event_count,
+ events_sha256: verified.events_sha256,
+ })
+ });
+ if result.is_err() {
+ let _ = fs::remove_dir_all(&stage);
+ }
+ result
+}
+
+fn create_backup_in_stage(
+ store_config: &PocketStoreConfig,
+ identity: &PortableRelayBackupIdentity,
+ stage: &Path,
+ policy: PortableRelayBackupPolicy,
+) -> Result<PortableRelayBackupReport, String> {
+ let store = PocketStoreHandle::open(store_config).map_err(|error| error.to_string())?;
+ store.sync().map_err(|error| error.to_string())?;
+ let events = store.scan_events().map_err(|error| error.to_string())?;
+ let event_count = u64::try_from(events.len()).expect("event count fits u64");
+ if event_count > policy.maximum_event_count() {
+ return Err("portable relay backup event count exceeds policy".to_owned());
+ }
+ let events_path = stage.join(EVENTS_FILE);
+ let mut writer = BufWriter::new(
+ File::create(&events_path)
+ .map_err(|error| format!("failed to create {}: {error}", events_path.display()))?,
+ );
+ for stored in &events {
+ let line = serde_json::to_vec(&serde_json::json!({
+ "store_offset": stored.store_offset(),
+ "event": pocket_event_json(stored.event())?,
+ }))
+ .map_err(|error| error.to_string())?;
+ if u64::try_from(line.len()).expect("line length fits u64")
+ > policy.maximum_event_json_bytes()
+ {
+ return Err("portable relay backup event JSON exceeds policy".to_owned());
+ }
+ writer
+ .write_all(&line)
+ .and_then(|_| writer.write_all(b"\n"))
+ .map_err(|error| format!("failed to write {}: {error}", events_path.display()))?;
+ }
+ writer
+ .flush()
+ .map_err(|error| format!("failed to flush {}: {error}", events_path.display()))?;
+ writer
+ .get_ref()
+ .sync_all()
+ .map_err(|error| format!("failed to sync {}: {error}", events_path.display()))?;
+ drop(writer);
+ let (events_sha256, events_size_bytes) = file_sha256(&events_path)?;
+ if events_size_bytes > policy.maximum_backup_bytes() {
+ return Err("portable relay backup byte size exceeds policy".to_owned());
+ }
+ let created_at_unix_seconds = now_unix_seconds()?;
+ let first_store_offset = events.first().map(|event| event.store_offset());
+ let last_store_offset = events.last().map(|event| event.store_offset());
+ let manifest = PortableRelayBackupManifest {
+ schema: BACKUP_SCHEMA.to_owned(),
+ tangle_version: TANGLE_RELAY_VERSION.to_owned(),
+ pocket_source_revision: POCKET_SOURCE_REVISION.to_owned(),
+ relay_url: identity.relay_url().to_owned(),
+ created_at_unix_seconds,
+ events: PortableRelayBackupEvents {
+ path: EVENTS_FILE.to_owned(),
+ count: event_count,
+ sha256: events_sha256.clone(),
+ size_bytes: events_size_bytes,
+ first_store_offset,
+ last_store_offset,
+ },
+ };
+ let manifest_path = stage.join(MANIFEST_FILE);
+ let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|error| error.to_string())?;
+ let mut manifest_file = File::create(&manifest_path)
+ .map_err(|error| format!("failed to create {}: {error}", manifest_path.display()))?;
+ manifest_file
+ .write_all(&manifest_bytes)
+ .and_then(|_| manifest_file.sync_all())
+ .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?;
+ verify_portable_relay_backup(stage, identity, policy)?;
+ Ok(PortableRelayBackupReport {
+ backup_path: stage.display().to_string(),
+ relay_url: identity.relay_url().to_owned(),
+ created_at_unix_seconds,
+ event_count,
+ events_sha256,
+ events_size_bytes,
+ first_store_offset,
+ last_store_offset,
+ })
+}
+
+fn restore_into_stage(
+ input: &Path,
+ stage: &Path,
+ policy: PortableRelayBackupPolicy,
+) -> Result<u64, String> {
+ let config = PocketStoreConfig::new(stage, PocketSyncPolicy::FlushOnWrite)
+ .map_err(|error| error.to_string())?;
+ let store = PocketStoreHandle::open(&config).map_err(|error| error.to_string())?;
+ let events_path = input.join(EVENTS_FILE);
+ let mut reader = BufReader::new(
+ File::open(&events_path)
+ .map_err(|error| format!("failed to open {}: {error}", events_path.display()))?,
+ );
+ let mut line = Vec::new();
+ let mut count = 0_u64;
+ loop {
+ line.clear();
+ if reader
+ .read_until(b'\n', &mut line)
+ .map_err(|error| format!("failed to read {}: {error}", events_path.display()))?
+ == 0
+ {
+ break;
+ }
+ let parsed = parse_backup_line(&line, policy)?;
+ store
+ .store_event(&parsed.event)
+ .map_err(|error| error.to_string())?;
+ count = count
+ .checked_add(1)
+ .ok_or_else(|| "portable relay restore event count overflowed".to_owned())?;
+ }
+ store.sync().map_err(|error| error.to_string())?;
+ let restored = store.scan_events().map_err(|error| error.to_string())?;
+ if u64::try_from(restored.len()).expect("event count fits u64") != count {
+ return Err("portable relay restore verification count differs".to_owned());
+ }
+ Ok(count)
+}
+
+struct VerifiedEventInventory {
+ count: u64,
+ first_store_offset: Option<u64>,
+ last_store_offset: Option<u64>,
+}
+
+fn verify_event_lines(
+ path: &Path,
+ policy: PortableRelayBackupPolicy,
+) -> Result<VerifiedEventInventory, String> {
+ let mut reader = BufReader::new(
+ File::open(path).map_err(|error| format!("failed to open {}: {error}", path.display()))?,
+ );
+ let mut line = Vec::new();
+ let mut count = 0_u64;
+ let mut first_store_offset = None;
+ let mut last_store_offset = None;
+ let mut event_ids = BTreeSet::new();
+ loop {
+ line.clear();
+ if reader
+ .read_until(b'\n', &mut line)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?
+ == 0
+ {
+ break;
+ }
+ let parsed = parse_backup_line(&line, policy)?;
+ parsed
+ .event
+ .verify()
+ .map_err(|_| "portable relay backup contains an invalid event signature".to_owned())?;
+ if last_store_offset.is_some_and(|offset| parsed.store_offset <= offset) {
+ return Err(
+ "portable relay backup store offsets are not strictly increasing".to_owned(),
+ );
+ }
+ if !event_ids.insert(parsed.event.id().as_hex_string()) {
+ return Err("portable relay backup contains duplicate event IDs".to_owned());
+ }
+ first_store_offset.get_or_insert(parsed.store_offset);
+ last_store_offset = Some(parsed.store_offset);
+ count = count
+ .checked_add(1)
+ .ok_or_else(|| "portable relay backup event count overflowed".to_owned())?;
+ if count > policy.maximum_event_count() {
+ return Err("portable relay backup event count exceeds policy".to_owned());
+ }
+ }
+ Ok(VerifiedEventInventory {
+ count,
+ first_store_offset,
+ last_store_offset,
+ })
+}
+
+struct ParsedBackupLine {
+ store_offset: u64,
+ event: tangle_store_pocket::PocketOwnedEvent,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct BackupLineDocument {
+ store_offset: u64,
+ event: serde_json::Value,
+}
+
+fn parse_backup_line(
+ line: &[u8],
+ policy: PortableRelayBackupPolicy,
+) -> Result<ParsedBackupLine, String> {
+ let line = line.strip_suffix(b"\n").unwrap_or(line);
+ if line.is_empty()
+ || u64::try_from(line.len()).expect("line length fits u64")
+ > policy.maximum_event_json_bytes()
+ {
+ return Err("portable relay backup event line is empty or oversized".to_owned());
+ }
+ let value: BackupLineDocument = serde_json::from_slice(line)
+ .map_err(|_| "portable relay backup line is invalid".to_owned())?;
+ let raw = serde_json::to_vec(&value.event).map_err(|error| error.to_string())?;
+ let event = parse_pocket_event_json(&raw).map_err(|error| error.to_string())?;
+ Ok(ParsedBackupLine {
+ store_offset: value.store_offset,
+ event,
+ })
+}
+
+fn pocket_event_json(event: &PocketEvent) -> Result<serde_json::Value, String> {
+ let tags = event
+ .tags()
+ .map_err(|error| error.to_string())?
+ .iter()
+ .map(|tag| {
+ tag.map(|value| {
+ str::from_utf8(value)
+ .map(str::to_owned)
+ .map_err(|error| error.to_string())
+ })
+ .collect::<Result<Vec<_>, _>>()
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ let content = str::from_utf8(event.content()).map_err(|error| error.to_string())?;
+ Ok(serde_json::json!({
+ "id": event.id().as_hex_string(),
+ "pubkey": event.pubkey().as_hex_string(),
+ "created_at": event.created_at().as_u64(),
+ "kind": event.kind().as_u16(),
+ "tags": tags,
+ "content": content,
+ "sig": event.sig().to_string(),
+ }))
+}
+
+fn read_manifest(input: &Path) -> Result<PortableRelayBackupManifest, String> {
+ let metadata = fs::symlink_metadata(input)
+ .map_err(|error| format!("failed to stat {}: {error}", input.display()))?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err("portable relay backup input must be a real directory".to_owned());
+ }
+ let path = input.join(MANIFEST_FILE);
+ let metadata = regular_file_metadata(&path)?;
+ if metadata.len() > 64 * 1024 {
+ return Err("portable relay backup manifest exceeds size policy".to_owned());
+ }
+ let raw =
+ fs::read(&path).map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ serde_json::from_slice(&raw).map_err(|_| "portable relay backup manifest is invalid".to_owned())
+}
+
+fn regular_file_metadata(path: &Path) -> Result<fs::Metadata, String> {
+ let metadata = fs::symlink_metadata(path)
+ .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err(format!(
+ "portable relay backup file is unsafe: {}",
+ path.display()
+ ));
+ }
+ Ok(metadata)
+}
+
+fn file_sha256(path: &Path) -> Result<(String, u64), String> {
+ let mut file =
+ File::open(path).map_err(|error| format!("failed to open {}: {error}", path.display()))?;
+ let mut digest = Sha256::new();
+ let mut size = 0_u64;
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ if read == 0 {
+ break;
+ }
+ digest.update(&buffer[..read]);
+ size = size
+ .checked_add(u64::try_from(read).expect("read length fits u64"))
+ .ok_or_else(|| "portable relay backup size overflowed".to_owned())?;
+ }
+ Ok((lower_hex(&digest.finalize()), size))
+}
+
+fn validate_final_backup_path(path: &Path) -> Result<(), String> {
+ if path.as_os_str().is_empty() {
+ return Err("portable relay backup path must not be empty".to_owned());
+ }
+ for component in path.components() {
+ if matches!(component, Component::ParentDir) {
+ return Err("portable relay backup path must not contain parent traversal".to_owned());
+ }
+ }
+ Ok(())
+}
+
+fn staging_path(path: &Path) -> Result<PathBuf, String> {
+ let name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| "portable relay backup path requires a UTF-8 file name".to_owned())?;
+ let nonce = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|error| error.to_string())?
+ .as_nanos();
+ Ok(path.with_file_name(format!(".{name}.stage-{}-{nonce}", std::process::id())))
+}
+
+fn now_unix_seconds() -> Result<u64, String> {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .map_err(|error| error.to_string())
+}
+
+fn lower_hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ output.push(char::from(HEX[usize::from(byte >> 4)]));
+ output.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ output
+}
+
+fn is_lower_hex(value: &str, expected: usize) -> bool {
+ value.len() == expected
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ PortableRelayBackupIdentity, PortableRelayBackupPolicy, create_portable_relay_backup,
+ restore_portable_relay_backup, verify_portable_relay_backup,
+ };
+ use crate::pocket_conversion::tangle_event_to_pocket;
+ use std::path::PathBuf;
+ use tangle_store_pocket::{
+ PocketStoreConfig, PocketStoreHandle, PocketSyncPolicy, parse_pocket_filter_json,
+ };
+ use tangle_test_support::{FixtureKey, build_fixture_event_from_parts};
+
+ #[test]
+ fn portable_backup_round_trip_preserves_profile_head_and_delete_event() {
+ let root = temp_root("round-trip");
+ let _ = std::fs::remove_dir_all(&root);
+ let source = root.join("source");
+ let backup = root.join("backups").join("backup-1");
+ let restored = root.join("restored");
+ let source_config =
+ PocketStoreConfig::new(&source, PocketSyncPolicy::FlushOnWrite).expect("source config");
+ let source_store = PocketStoreHandle::open(&source_config).expect("source store");
+ for event in [
+ event(1_714_124_433, 0, Vec::new(), r#"{"name":"Farm"}"#),
+ event(1_714_124_434, 0, Vec::new(), r#"{"name":"Farm Market"}"#),
+ event(
+ 1_714_124_435,
+ 5,
+ vec![vec!["e".to_owned(), "0".repeat(64)]],
+ "deleted",
+ ),
+ ] {
+ source_store
+ .store_event(&tangle_event_to_pocket(&event).expect("pocket event"))
+ .expect("store event");
+ }
+ let identity =
+ PortableRelayBackupIdentity::new("wss://relay.radroots.test").expect("identity");
+ let policy = policy();
+
+ let created = create_portable_relay_backup(&source_config, &identity, &backup, policy)
+ .expect("backup");
+ let verified =
+ verify_portable_relay_backup(&backup, &identity, policy).expect("verify backup");
+ let restore =
+ restore_portable_relay_backup(&backup, &identity, &restored, policy).expect("restore");
+
+ assert_eq!(created.event_count, 3);
+ assert_eq!(verified.event_count, 3);
+ assert_eq!(restore.event_count, 3);
+ assert_eq!(created.events_sha256, restore.events_sha256);
+ let restored_config = PocketStoreConfig::new(&restored, PocketSyncPolicy::FlushOnWrite)
+ .expect("restored config");
+ let restored_store = PocketStoreHandle::open(&restored_config).expect("restored store");
+ let profile_filter = parse_pocket_filter_json(
+ format!(
+ r#"{{"authors":["{}"],"kinds":[0]}}"#,
+ FixtureKey::Member.public_key()
+ )
+ .as_bytes(),
+ )
+ .expect("profile filter");
+ let profile = restored_store
+ .find_events(
+ &profile_filter,
+ tangle_store_pocket::PocketQueryConfig::default(),
+ )
+ .expect("profile query");
+ assert_eq!(profile.len(), 1);
+ assert_eq!(profile[0].content(), br#"{"name":"Farm Market"}"#);
+ let delete_filter = parse_pocket_filter_json(
+ format!(
+ r#"{{"authors":["{}"],"kinds":[5]}}"#,
+ FixtureKey::Member.public_key()
+ )
+ .as_bytes(),
+ )
+ .expect("delete filter");
+ assert_eq!(
+ restored_store
+ .find_events(
+ &delete_filter,
+ tangle_store_pocket::PocketQueryConfig::default()
+ )
+ .expect("delete query")
+ .len(),
+ 1
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn portable_backup_detects_corruption_identity_drift_and_existing_restore_target() {
+ let root = temp_root("adverse");
+ let _ = std::fs::remove_dir_all(&root);
+ let source_config =
+ PocketStoreConfig::new(root.join("source"), PocketSyncPolicy::FlushOnWrite)
+ .expect("source config");
+ let source = PocketStoreHandle::open(&source_config).expect("source");
+ source
+ .store_event(
+ &tangle_event_to_pocket(&event(1_714_124_433, 1, Vec::new(), "note"))
+ .expect("pocket"),
+ )
+ .expect("store");
+ let identity =
+ PortableRelayBackupIdentity::new("wss://relay.radroots.test").expect("identity");
+ let backup = root.join("backup");
+ create_portable_relay_backup(&source_config, &identity, &backup, policy()).expect("backup");
+ assert!(
+ verify_portable_relay_backup(
+ &backup,
+ &PortableRelayBackupIdentity::new("wss://other.test").expect("other"),
+ policy(),
+ )
+ .is_err()
+ );
+ let target = root.join("existing");
+ std::fs::create_dir_all(&target).expect("target");
+ assert!(restore_portable_relay_backup(&backup, &identity, &target, policy()).is_err());
+ std::fs::write(backup.join("events.jsonl"), b"corrupt\n").expect("corrupt");
+ assert!(verify_portable_relay_backup(&backup, &identity, policy()).is_err());
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ fn event(
+ created_at: u64,
+ kind: u64,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ ) -> tangle_protocol::Event {
+ build_fixture_event_from_parts(FixtureKey::Member, created_at, kind, tags, content)
+ .expect("event")
+ }
+
+ fn policy() -> PortableRelayBackupPolicy {
+ PortableRelayBackupPolicy::new(100, 128 * 1024, 1024 * 1024).expect("policy")
+ }
+
+ fn temp_root(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!(
+ "tangle-portable-backup-{name}-{}",
+ std::process::id()
+ ))
+ }
+}