1use anyhow::Result;
10use git_remote_htree::nostr_client::{hashtree_root_kinds, is_hashtree_root_kind, load_keys};
11use hashtree_core::{from_hex, to_hex, Cid};
12use nostr_sdk::prelude::*;
13use std::collections::{HashMap, HashSet, VecDeque};
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::sync::RwLock;
18use tracing::{error, info, warn};
19
20use crate::fetch::{FetchConfig, Fetcher};
21use crate::storage::{HashtreeStore, PRIORITY_FOLLOWED, PRIORITY_OWN};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum SyncPriority {
26 Pinned = 0,
28 TrackedAuthor = 1,
30 Own = 2,
32 Followed = 3,
34}
35
36#[derive(Debug, Clone)]
38pub struct SyncTask {
39 pub key: String,
41 pub cid: Cid,
43 pub priority: SyncPriority,
45 pub queued_at: Instant,
47}
48
49#[derive(Debug, Clone)]
51pub struct SyncConfig {
52 pub sync_own: bool,
54 pub sync_followed: bool,
56 pub relays: Vec<String>,
58 pub max_concurrent: usize,
60 pub blossom_timeout_ms: u64,
62}
63
64impl Default for SyncConfig {
65 fn default() -> Self {
66 Self {
67 sync_own: true,
68 sync_followed: true,
69 relays: hashtree_config::DEFAULT_RELAYS
70 .iter()
71 .map(|s| s.to_string())
72 .collect(),
73 max_concurrent: 3,
74 blossom_timeout_ms: 10000,
75 }
76 }
77}
78
79impl SyncConfig {
80 pub fn from_config(config: &hashtree_config::Config) -> Self {
82 Self {
83 sync_own: true,
84 sync_followed: true,
85 relays: config.nostr.relays.clone(),
86 max_concurrent: 3,
87 blossom_timeout_ms: 10000,
88 }
89 }
90}
91
92#[allow(dead_code)]
94struct TreeSubscription {
95 key: String,
96 current_cid: Option<Cid>,
97 priority: SyncPriority,
98 last_synced: Option<Instant>,
99}
100
101fn build_exact_tree_filter(key: &str) -> Result<Filter> {
102 let (npub, tree_name) = key
103 .split_once('/')
104 .ok_or_else(|| anyhow::anyhow!("Invalid pinned ref key: {}", key))?;
105 let author = PublicKey::from_bech32(npub)
106 .map_err(|_| anyhow::anyhow!("Invalid npub in pinned ref key: {}", key))?;
107
108 Ok(Filter::new()
109 .kinds(hashtree_root_kinds())
110 .author(author)
111 .custom_tag(
112 SingleLetterTag::lowercase(Alphabet::D),
113 tree_name.to_string(),
114 )
115 .custom_tag(SingleLetterTag::lowercase(Alphabet::L), "hashtree"))
116}
117
118fn build_author_tree_filter(author: PublicKey) -> Filter {
119 Filter::new()
120 .kinds(hashtree_root_kinds())
121 .author(author)
122 .custom_tag(SingleLetterTag::lowercase(Alphabet::L), "hashtree")
123}
124
125fn load_author_signing_keys() -> HashMap<String, Keys> {
126 load_keys()
127 .into_iter()
128 .filter_map(|stored| {
129 let secret_hex = stored.secret_hex?;
130 let secret_bytes = hex::decode(&secret_hex).ok()?;
131 let secret = SecretKey::from_slice(&secret_bytes).ok()?;
132 Some((stored.pubkey_hex, Keys::new(secret)))
133 })
134 .collect()
135}
136
137fn cid_from_tree_event(event: &Event, author_keys: Option<&Keys>) -> Option<Cid> {
138 let mut hash_hex: Option<String> = None;
139 let mut key_hex: Option<String> = None;
140 let mut encrypted_key: Option<String> = None;
141 let mut self_encrypted_key: Option<String> = None;
142
143 for tag in event.tags.iter() {
144 let tag_vec = tag.as_slice();
145 if tag_vec.len() < 2 {
146 continue;
147 }
148
149 match tag_vec[0].as_str() {
150 "hash" => hash_hex = Some(tag_vec[1].clone()),
151 "key" => key_hex = Some(tag_vec[1].clone()),
152 "encryptedKey" => encrypted_key = Some(tag_vec[1].clone()),
153 "selfEncryptedKey" => self_encrypted_key = Some(tag_vec[1].clone()),
154 _ => {}
155 }
156 }
157
158 let hash = from_hex(&hash_hex?).ok()?;
159
160 if let Some(key_hex) = key_hex {
161 let bytes = hex::decode(&key_hex).ok()?;
162 if bytes.len() != 32 {
163 return None;
164 }
165 let mut key = [0u8; 32];
166 key.copy_from_slice(&bytes);
167 return Some(Cid {
168 hash,
169 key: Some(key),
170 });
171 }
172
173 if let Some(ciphertext) = self_encrypted_key {
174 let keys = author_keys?;
175 if keys.public_key() != event.pubkey {
176 return None;
177 }
178 let key_hex = nip44::decrypt(keys.secret_key(), &event.pubkey, &ciphertext).ok()?;
179 let bytes = hex::decode(&key_hex).ok()?;
180 if bytes.len() != 32 {
181 return None;
182 }
183 let mut key = [0u8; 32];
184 key.copy_from_slice(&bytes);
185 return Some(Cid {
186 hash,
187 key: Some(key),
188 });
189 }
190
191 if encrypted_key.is_some() {
192 return None;
193 }
194
195 Some(Cid { hash, key: None })
196}
197
198fn classify_sync_event(
199 key: &str,
200 author_hex: &str,
201 my_pubkey: &PublicKey,
202 pinned_refs: &HashSet<String>,
203 tracked_authors: &HashSet<String>,
204 followed_authors: &HashSet<String>,
205) -> Option<SyncPriority> {
206 if pinned_refs.contains(key) {
207 return Some(SyncPriority::Pinned);
208 }
209
210 if tracked_authors.contains(author_hex) {
211 return Some(SyncPriority::TrackedAuthor);
212 }
213
214 if author_hex == my_pubkey.to_hex() {
215 return Some(SyncPriority::Own);
216 }
217
218 if followed_authors.contains(author_hex) {
219 return Some(SyncPriority::Followed);
220 }
221
222 None
223}
224
225fn apply_synced_tree_update(store: &HashtreeStore, task: &SyncTask) -> Result<()> {
226 let (owner, name) = task
227 .key
228 .split_once('/')
229 .map(|(o, n)| (o.to_string(), Some(n)))
230 .unwrap_or((task.key.clone(), None));
231
232 let storage_priority = match task.priority {
233 SyncPriority::Pinned | SyncPriority::TrackedAuthor | SyncPriority::Own => PRIORITY_OWN,
234 SyncPriority::Followed => PRIORITY_FOLLOWED,
235 };
236
237 if matches!(
238 task.priority,
239 SyncPriority::Pinned | SyncPriority::TrackedAuthor
240 ) {
241 store.pin(&task.cid.hash)?;
242 }
243
244 store.index_tree(
245 &task.cid.hash,
246 &owner,
247 name,
248 storage_priority,
249 Some(&task.key),
250 )?;
251
252 store.evict_if_needed()?;
253 Ok(())
254}
255
256pub struct BackgroundSync {
258 config: SyncConfig,
259 store: Arc<HashtreeStore>,
260 client: Client,
262 my_pubkey: PublicKey,
264 subscriptions: Arc<RwLock<HashMap<String, TreeSubscription>>>,
266 followed_authors: Arc<RwLock<HashSet<String>>>,
268 pinned_refs: Arc<RwLock<HashSet<String>>>,
270 tracked_authors: Arc<RwLock<HashSet<String>>>,
272 subscribed_pinned_refs: Arc<RwLock<HashSet<String>>>,
274 subscribed_tracked_authors: Arc<RwLock<HashSet<String>>>,
276 author_signing_keys: Arc<RwLock<HashMap<String, Keys>>>,
278 queue: Arc<RwLock<VecDeque<SyncTask>>>,
280 syncing: Arc<RwLock<HashSet<String>>>,
282 shutdown_tx: tokio::sync::watch::Sender<bool>,
284 shutdown_rx: tokio::sync::watch::Receiver<bool>,
285 fetcher: Arc<Fetcher>,
287}
288
289impl BackgroundSync {
290 pub async fn new(config: SyncConfig, store: Arc<HashtreeStore>, keys: Keys) -> Result<Self> {
292 let my_pubkey = keys.public_key();
293 let client = Client::new(keys);
294
295 for relay in &config.relays {
297 if let Err(e) = client.add_relay(relay).await {
298 warn!("Failed to add relay {}: {}", relay, e);
299 }
300 }
301
302 client.connect().await;
304
305 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
306
307 let fetch_config = FetchConfig {
310 blossom_timeout: Duration::from_millis(config.blossom_timeout_ms),
311 };
312 let fetcher = Arc::new(Fetcher::new(fetch_config));
313
314 Ok(Self {
315 config,
316 store,
317 client,
318 my_pubkey,
319 subscriptions: Arc::new(RwLock::new(HashMap::new())),
320 followed_authors: Arc::new(RwLock::new(HashSet::new())),
321 pinned_refs: Arc::new(RwLock::new(HashSet::new())),
322 tracked_authors: Arc::new(RwLock::new(HashSet::new())),
323 subscribed_pinned_refs: Arc::new(RwLock::new(HashSet::new())),
324 subscribed_tracked_authors: Arc::new(RwLock::new(HashSet::new())),
325 author_signing_keys: Arc::new(RwLock::new(load_author_signing_keys())),
326 queue: Arc::new(RwLock::new(VecDeque::new())),
327 syncing: Arc::new(RwLock::new(HashSet::new())),
328 shutdown_tx,
329 shutdown_rx,
330 fetcher,
331 })
332 }
333
334 pub async fn run(&self, contacts_file: PathBuf) -> Result<()> {
336 info!("Starting background sync service");
337
338 tokio::time::sleep(Duration::from_secs(3)).await;
340
341 self.refresh_author_signing_keys().await;
342 self.refresh_pinned_ref_subscriptions().await?;
343 self.refresh_tracked_author_subscriptions().await?;
344
345 if self.config.sync_own {
347 self.subscribe_own_trees().await?;
348 }
349
350 if self.config.sync_followed {
352 self.subscribe_followed_trees(&contacts_file).await?;
353 }
354
355 let queue = self.queue.clone();
357 let syncing = self.syncing.clone();
358 let store = self.store.clone();
359 let fetcher = self.fetcher.clone();
360 let max_concurrent = self.config.max_concurrent;
361 let mut shutdown_rx = self.shutdown_rx.clone();
362
363 tokio::spawn(async move {
365 let mut interval = tokio::time::interval(Duration::from_millis(500));
366
367 loop {
368 tokio::select! {
369 _ = shutdown_rx.changed() => {
370 if *shutdown_rx.borrow() {
371 info!("Sync worker shutting down");
372 break;
373 }
374 }
375 _ = interval.tick() => {
376 let current_syncing = syncing.read().await.len();
378 if current_syncing >= max_concurrent {
379 continue;
380 }
381
382 let task = {
384 let mut q = queue.write().await;
385 q.pop_front()
386 };
387
388 if let Some(task) = task {
389 let hash_hex = to_hex(&task.cid.hash);
390
391 {
393 let mut s = syncing.write().await;
394 if s.contains(&hash_hex) {
395 continue;
396 }
397 s.insert(hash_hex.clone());
398 }
399
400 let syncing_clone = syncing.clone();
402 let store_clone = store.clone();
403 let fetcher_clone = fetcher.clone();
404
405 tokio::spawn(async move {
406 let result = fetcher_clone.fetch_cid_tree(
407 &store_clone,
408 &task.cid,
409 ).await;
410
411 match result {
412 Ok((chunks_fetched, bytes_fetched)) => {
413 if chunks_fetched > 0 {
414 info!(
415 "Synced tree {} ({} chunks, {} bytes)",
416 &hash_hex[..12],
417 chunks_fetched,
418 bytes_fetched
419 );
420 } else {
421 tracing::debug!(
422 "Tree {} already present locally; applying ref update",
423 &hash_hex[..12]
424 );
425 }
426
427 match store_clone.blob_exists(&task.cid.hash) {
428 Ok(true) => {}
429 Ok(false) => {
430 warn!(
431 "Skipping ref update for {} because root {} is still missing locally",
432 task.key,
433 &hash_hex[..12]
434 );
435 syncing_clone.write().await.remove(&hash_hex);
436 return;
437 }
438 Err(err) => {
439 warn!(
440 "Failed to verify synced root {} before indexing {}: {}",
441 &hash_hex[..12],
442 task.key,
443 err
444 );
445 syncing_clone.write().await.remove(&hash_hex);
446 return;
447 }
448 }
449
450 if let Err(e) = apply_synced_tree_update(&store_clone, &task) {
451 warn!("Failed to apply synced tree {}: {}", &hash_hex[..12], e);
452 }
453 }
454 Err(e) => {
455 warn!("Failed to sync tree {}: {}", &hash_hex[..12], e);
456 }
457 }
458
459 syncing_clone.write().await.remove(&hash_hex);
461 });
462 }
463 }
464 }
465 }
466 });
467
468 let mut notifications = self.client.notifications();
470 let subscriptions = self.subscriptions.clone();
471 let queue = self.queue.clone();
472 let mut pinned_refresh = tokio::time::interval(Duration::from_secs(5));
473 let mut shutdown_rx = self.shutdown_rx.clone();
474
475 loop {
476 tokio::select! {
477 _ = shutdown_rx.changed() => {
478 if *shutdown_rx.borrow() {
479 info!("Background sync shutting down");
480 break;
481 }
482 }
483 _ = pinned_refresh.tick() => {
484 self.refresh_author_signing_keys().await;
485 if let Err(err) = self.refresh_pinned_ref_subscriptions().await {
486 warn!("Failed to refresh pinned ref subscriptions: {}", err);
487 }
488 if let Err(err) = self.refresh_tracked_author_subscriptions().await {
489 warn!("Failed to refresh tracked author subscriptions: {}", err);
490 }
491 }
492 notification = notifications.recv() => {
493 match notification {
494 Ok(RelayPoolNotification::Event { event, .. }) => {
495 self.handle_tree_event(&event, &subscriptions, &queue).await;
496 }
497 Ok(_) => {}
498 Err(e) => {
499 error!("Notification error: {}", e);
500 break;
501 }
502 }
503 }
504 }
505 }
506
507 Ok(())
508 }
509
510 async fn refresh_pinned_ref_subscriptions(&self) -> Result<()> {
511 let current_refs: HashSet<String> = self.store.list_pinned_refs()?.into_iter().collect();
512 {
513 let mut pinned_refs = self.pinned_refs.write().await;
514 *pinned_refs = current_refs.clone();
515 }
516
517 {
518 let mut subscriptions = self.subscriptions.write().await;
519 subscriptions.retain(|key, sub| {
520 sub.priority != SyncPriority::Pinned || current_refs.contains(key)
521 });
522 }
523
524 let new_refs: Vec<String> = {
525 let subscribed = self.subscribed_pinned_refs.read().await;
526 current_refs
527 .iter()
528 .filter(|key| !subscribed.contains(*key))
529 .cloned()
530 .collect()
531 };
532
533 for key in new_refs {
534 let filter = match build_exact_tree_filter(&key) {
535 Ok(filter) => filter,
536 Err(err) => {
537 warn!("Ignoring invalid pinned ref {}: {}", key, err);
538 continue;
539 }
540 };
541
542 match self.client.subscribe(filter, None).await {
543 Ok(_) => {
544 info!("Subscribed to pinned ref {}", key);
545 self.subscribed_pinned_refs.write().await.insert(key);
546 }
547 Err(err) => {
548 warn!(
549 "Failed to subscribe to pinned ref (will retry on refresh): {}",
550 err
551 );
552 }
553 }
554 }
555
556 Ok(())
557 }
558
559 async fn refresh_author_signing_keys(&self) {
560 let mut author_signing_keys = self.author_signing_keys.write().await;
561 *author_signing_keys = load_author_signing_keys();
562 }
563
564 async fn refresh_tracked_author_subscriptions(&self) -> Result<()> {
565 let tracked_npubs = self.store.list_tracked_authors()?;
566 let parsed_authors: Vec<(String, PublicKey, String)> = tracked_npubs
567 .into_iter()
568 .filter_map(|npub| match PublicKey::from_bech32(&npub) {
569 Ok(pubkey) => Some((npub, pubkey, pubkey.to_hex())),
570 Err(err) => {
571 warn!("Ignoring invalid tracked author {}: {}", npub, err);
572 None
573 }
574 })
575 .collect();
576 let current_authors: HashSet<String> = parsed_authors
577 .iter()
578 .map(|(_, _, author_hex)| author_hex.clone())
579 .collect();
580
581 {
582 let mut tracked_authors = self.tracked_authors.write().await;
583 *tracked_authors = current_authors.clone();
584 }
585
586 {
587 let mut subscriptions = self.subscriptions.write().await;
588 subscriptions.retain(|key, sub| {
589 if sub.priority != SyncPriority::TrackedAuthor {
590 return true;
591 }
592
593 let Some((npub, _)) = key.split_once('/') else {
594 return false;
595 };
596 let Ok(author) = PublicKey::from_bech32(npub) else {
597 return false;
598 };
599 current_authors.contains(&author.to_hex())
600 });
601 }
602
603 let new_authors: Vec<(String, PublicKey)> = {
604 let subscribed = self.subscribed_tracked_authors.read().await;
605 parsed_authors
606 .iter()
607 .filter(|(_, _, author_hex)| !subscribed.contains(author_hex))
608 .map(|(_, pubkey, author_hex)| (author_hex.clone(), *pubkey))
609 .collect()
610 };
611
612 for (author_hex, author) in new_authors {
613 match self
614 .client
615 .subscribe(build_author_tree_filter(author), None)
616 .await
617 {
618 Ok(_) => {
619 info!(
620 "Subscribed to tracked author {}",
621 author.to_bech32().unwrap_or(author_hex.clone())
622 );
623 self.subscribed_tracked_authors
624 .write()
625 .await
626 .insert(author_hex);
627 }
628 Err(err) => {
629 warn!(
630 "Failed to subscribe to tracked author (will retry on refresh): {}",
631 err
632 );
633 }
634 }
635 }
636
637 Ok(())
638 }
639
640 async fn subscribe_own_trees(&self) -> Result<()> {
642 let filter = build_author_tree_filter(self.my_pubkey);
643
644 match self.client.subscribe(filter, None).await {
645 Ok(_) => {
646 info!(
647 "Subscribed to own trees for {}",
648 self.my_pubkey.to_bech32().unwrap_or_default()
649 );
650 }
651 Err(e) => {
652 warn!(
653 "Failed to subscribe to own trees (will retry on reconnect): {}",
654 e
655 );
656 }
657 }
658
659 Ok(())
660 }
661
662 async fn subscribe_followed_trees(&self, contacts_file: &PathBuf) -> Result<()> {
664 let contacts: Vec<String> = if contacts_file.exists() {
666 let data = std::fs::read_to_string(contacts_file)?;
667 serde_json::from_str(&data).unwrap_or_default()
668 } else {
669 Vec::new()
670 };
671
672 if contacts.is_empty() {
673 self.followed_authors.write().await.clear();
674 info!("No contacts to subscribe to");
675 return Ok(());
676 }
677
678 {
679 let mut followed_authors = self.followed_authors.write().await;
680 *followed_authors = contacts.iter().cloned().collect();
681 }
682
683 let pubkeys: Vec<PublicKey> = contacts
685 .iter()
686 .filter_map(|hex| PublicKey::from_hex(hex).ok())
687 .collect();
688
689 if pubkeys.is_empty() {
690 return Ok(());
691 }
692
693 let filter = Filter::new()
695 .kinds(hashtree_root_kinds())
696 .authors(pubkeys.clone())
697 .custom_tag(SingleLetterTag::lowercase(Alphabet::L), "hashtree");
698
699 match self.client.subscribe(filter, None).await {
700 Ok(_) => {
701 info!("Subscribed to {} followed users' trees", pubkeys.len());
702 }
703 Err(e) => {
704 warn!(
705 "Failed to subscribe to followed trees (will retry on reconnect): {}",
706 e
707 );
708 }
709 }
710
711 Ok(())
712 }
713
714 async fn handle_tree_event(
716 &self,
717 event: &Event,
718 subscriptions: &Arc<RwLock<HashMap<String, TreeSubscription>>>,
719 queue: &Arc<RwLock<VecDeque<SyncTask>>>,
720 ) {
721 let has_hashtree_tag = event.tags.iter().any(|tag| {
723 let v = tag.as_slice();
724 v.len() >= 2 && v[0] == "l" && v[1] == "hashtree"
725 });
726
727 if !has_hashtree_tag || !is_hashtree_root_kind(event.kind) {
728 return;
729 }
730
731 let d_tag = event.tags.iter().find_map(|tag| {
733 if let Some(TagStandard::Identifier(id)) = tag.as_standardized() {
734 Some(id.clone())
735 } else {
736 None
737 }
738 });
739
740 let tree_name = match d_tag {
741 Some(name) => name,
742 None => return,
743 };
744
745 let npub = event
747 .pubkey
748 .to_bech32()
749 .unwrap_or_else(|_| event.pubkey.to_hex());
750 let key = format!("{}/{}", npub, tree_name);
751
752 let author_hex = event.pubkey.to_hex();
753 let pinned_refs = self.pinned_refs.read().await.clone();
754 let tracked_authors = self.tracked_authors.read().await.clone();
755 let followed_authors = self.followed_authors.read().await.clone();
756
757 let Some(priority) = classify_sync_event(
759 &key,
760 &author_hex,
761 &self.my_pubkey,
762 &pinned_refs,
763 &tracked_authors,
764 &followed_authors,
765 ) else {
766 return;
767 };
768
769 let author_keys = self
770 .author_signing_keys
771 .read()
772 .await
773 .get(&author_hex)
774 .cloned();
775 let Some(cid) = cid_from_tree_event(event, author_keys.as_ref()) else {
776 return;
777 };
778
779 let should_sync = {
781 let mut subs = subscriptions.write().await;
782 let sub = subs.entry(key.clone()).or_insert(TreeSubscription {
783 key: key.clone(),
784 current_cid: None,
785 priority,
786 last_synced: None,
787 });
788
789 let changed = sub.current_cid.as_ref().map(|c| c.hash) != Some(cid.hash);
791 if changed {
792 sub.current_cid = Some(cid.clone());
793 true
794 } else {
795 false
796 }
797 };
798
799 if should_sync {
800 info!(
801 "New tree update: {} -> {}",
802 key,
803 to_hex(&cid.hash)[..12].to_string()
804 );
805
806 let task = SyncTask {
808 key,
809 cid,
810 priority,
811 queued_at: Instant::now(),
812 };
813
814 let mut q = queue.write().await;
815
816 let insert_pos = q
818 .iter()
819 .position(|t| t.priority > task.priority)
820 .unwrap_or(q.len());
821 q.insert(insert_pos, task);
822 }
823 }
824
825 pub fn shutdown(&self) {
827 let _ = self.shutdown_tx.send(true);
828 }
829
830 pub async fn queue_sync(&self, key: &str, cid: Cid, priority: SyncPriority) {
832 let task = SyncTask {
833 key: key.to_string(),
834 cid,
835 priority,
836 queued_at: Instant::now(),
837 };
838
839 let mut q = self.queue.write().await;
840 let insert_pos = q
841 .iter()
842 .position(|t| t.priority > task.priority)
843 .unwrap_or(q.len());
844 q.insert(insert_pos, task);
845 }
846
847 pub async fn status(&self) -> SyncStatus {
849 let subscriptions = self.subscriptions.read().await;
850 let queue = self.queue.read().await;
851 let syncing = self.syncing.read().await;
852
853 SyncStatus {
854 subscribed_trees: subscriptions.len(),
855 queued_tasks: queue.len(),
856 active_syncs: syncing.len(),
857 }
858 }
859}
860
861#[derive(Debug, Clone)]
863pub struct SyncStatus {
864 pub subscribed_trees: usize,
865 pub queued_tasks: usize,
866 pub active_syncs: usize,
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use git_remote_htree::nostr_client::KIND_HASHTREE_ROOT;
873 use nostr_sdk::Keys;
874 use std::fs;
875 use tempfile::TempDir;
876
877 fn upload_repo_root(
878 store: &HashtreeStore,
879 base: &std::path::Path,
880 name: &str,
881 body: &str,
882 ) -> Cid {
883 let dir = base.join(name);
884 fs::create_dir_all(&dir).expect("create repo dir");
885 fs::write(dir.join("README.md"), body).expect("write repo file");
886 let cid = store
887 .upload_dir_with_options(&dir, true)
888 .expect("upload repo directory");
889 let cid = Cid::parse(&cid).expect("parse repo cid");
890 store.unpin(&cid.hash).expect("clear upload auto-pin");
891 cid
892 }
893
894 #[test]
895 fn classify_sync_event_ignores_removed_pinned_refs() {
896 let keys = Keys::generate();
897 let author = Keys::generate().public_key();
898 let key = format!("{}/repo", author.to_bech32().expect("author npub"));
899
900 let priority = classify_sync_event(
901 &key,
902 &author.to_hex(),
903 &keys.public_key(),
904 &HashSet::new(),
905 &HashSet::new(),
906 &HashSet::new(),
907 );
908
909 assert_eq!(priority, None);
910 }
911
912 #[test]
913 fn classify_sync_event_prioritizes_tracked_authors() {
914 let keys = Keys::generate();
915 let author = Keys::generate().public_key();
916 let key = format!("{}/repo", author.to_bech32().expect("author npub"));
917
918 let mut tracked_authors = HashSet::new();
919 tracked_authors.insert(author.to_hex());
920
921 let priority = classify_sync_event(
922 &key,
923 &author.to_hex(),
924 &keys.public_key(),
925 &HashSet::new(),
926 &tracked_authors,
927 &HashSet::new(),
928 );
929
930 assert_eq!(priority, Some(SyncPriority::TrackedAuthor));
931 }
932
933 #[test]
934 fn tracked_author_private_event_uses_matching_local_key() {
935 let author = Keys::generate();
936 let root_hash = [0x11; 32];
937 let root_key = [0x22; 32];
938 let ciphertext = nip44::encrypt(
939 author.secret_key(),
940 &author.public_key(),
941 hex::encode(root_key),
942 nip44::Version::V2,
943 )
944 .expect("encrypt private root key");
945 let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), "")
946 .tags(vec![
947 Tag::identifier("backup".to_string()),
948 Tag::custom(
949 TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::L)),
950 vec!["hashtree"],
951 ),
952 Tag::custom(TagKind::Custom("hash".into()), vec![hex::encode(root_hash)]),
953 Tag::custom(TagKind::Custom("selfEncryptedKey".into()), vec![ciphertext]),
954 ])
955 .sign_with_keys(&author)
956 .expect("sign private root event");
957
958 let cid = cid_from_tree_event(&event, Some(&author)).expect("decrypt tracked private cid");
959
960 assert_eq!(cid.hash, root_hash);
961 assert_eq!(cid.key, Some(root_key));
962 }
963
964 #[test]
965 fn pinned_sync_update_replaces_old_root_pin() {
966 let temp_dir = TempDir::new().expect("temp dir");
967 let store = HashtreeStore::new(temp_dir.path().join("store")).expect("store");
968 let first_cid = upload_repo_root(&store, temp_dir.path(), "repo-v1", "version one\n");
969 let second_cid = upload_repo_root(&store, temp_dir.path(), "repo-v2", "version two\n");
970 let repo_key = format!(
971 "{}/repo",
972 Keys::generate()
973 .public_key()
974 .to_bech32()
975 .expect("repo owner npub")
976 );
977
978 let first_task = SyncTask {
979 key: repo_key.clone(),
980 cid: first_cid.clone(),
981 priority: SyncPriority::Pinned,
982 queued_at: Instant::now(),
983 };
984 apply_synced_tree_update(&store, &first_task).expect("apply first sync update");
985
986 assert!(store.is_pinned(&first_cid.hash).expect("first root pinned"));
987 assert_eq!(
988 store.get_tree_ref(&repo_key).expect("first tree ref"),
989 Some(first_cid.hash)
990 );
991
992 let second_task = SyncTask {
993 key: repo_key.clone(),
994 cid: second_cid.clone(),
995 priority: SyncPriority::Pinned,
996 queued_at: Instant::now(),
997 };
998 apply_synced_tree_update(&store, &second_task).expect("apply second sync update");
999
1000 assert!(
1001 !store
1002 .is_pinned(&first_cid.hash)
1003 .expect("first root pin status"),
1004 "updating a pinned ref should unpin the superseded root"
1005 );
1006 assert!(store
1007 .is_pinned(&second_cid.hash)
1008 .expect("second root pinned"));
1009 assert_eq!(
1010 store.get_tree_ref(&repo_key).expect("updated tree ref"),
1011 Some(second_cid.hash)
1012 );
1013 assert!(
1014 store
1015 .get_tree_meta(&first_cid.hash)
1016 .expect("first meta lookup")
1017 .is_none(),
1018 "superseded pinned root should be unindexed after update"
1019 );
1020 }
1021
1022 #[test]
1023 fn tracked_author_sync_update_replaces_old_root_pin() {
1024 let temp_dir = TempDir::new().expect("temp dir");
1025 let store = HashtreeStore::new(temp_dir.path().join("store")).expect("store");
1026 let first_cid = upload_repo_root(&store, temp_dir.path(), "repo-v1", "version one\n");
1027 let second_cid = upload_repo_root(&store, temp_dir.path(), "repo-v2", "version two\n");
1028 let repo_key = format!(
1029 "{}/repo",
1030 Keys::generate()
1031 .public_key()
1032 .to_bech32()
1033 .expect("repo owner npub")
1034 );
1035
1036 let first_task = SyncTask {
1037 key: repo_key.clone(),
1038 cid: first_cid.clone(),
1039 priority: SyncPriority::TrackedAuthor,
1040 queued_at: Instant::now(),
1041 };
1042 apply_synced_tree_update(&store, &first_task).expect("apply first tracked sync update");
1043
1044 assert!(store.is_pinned(&first_cid.hash).expect("first root pinned"));
1045 assert_eq!(
1046 store.get_tree_ref(&repo_key).expect("first tree ref"),
1047 Some(first_cid.hash)
1048 );
1049
1050 let second_task = SyncTask {
1051 key: repo_key.clone(),
1052 cid: second_cid.clone(),
1053 priority: SyncPriority::TrackedAuthor,
1054 queued_at: Instant::now(),
1055 };
1056 apply_synced_tree_update(&store, &second_task).expect("apply second tracked sync update");
1057
1058 assert!(
1059 !store
1060 .is_pinned(&first_cid.hash)
1061 .expect("first root pin status"),
1062 "updating a tracked author ref should unpin the superseded root"
1063 );
1064 assert!(store
1065 .is_pinned(&second_cid.hash)
1066 .expect("second root pinned"));
1067 assert_eq!(
1068 store.get_tree_ref(&repo_key).expect("updated tree ref"),
1069 Some(second_cid.hash)
1070 );
1071 }
1072}