Skip to main content

git_remote_htree/
nostr_client.rs

1//! Nostr client for publishing and fetching git repository references
2//!
3//! Uses kind 30064 hashtree root events, while still reading legacy kind 30078 roots:
4//! {
5//!   "kind": 30064,
6//!   "tags": [
7//!     ["d", "<repo-name>"],
8//!     ["l", "hashtree"]
9//!   ],
10//!   "content": "<merkle-root-hash>"
11//! }
12//!
13//! The merkle tree contains:
14//!   root/
15//!     refs/heads/main -> <sha>
16//!     refs/tags/v1.0 -> <sha>
17//!     objects/<sha1> -> data
18//!     objects/<sha2> -> data
19//!
20//! ## Identity file format
21//!
22//! The secrets file (`~/.hashtree/keys`) supports multiple signing keys with optional
23//! petnames:
24//! ```text
25//! nsec1... default
26//! nsec1... work
27//! nsec1... personal
28//! ```
29//!
30//! Or hex format:
31//! ```text
32//! <64-char-hex> default
33//! <64-char-hex> work
34//! ```
35//!
36//! Public read-only aliases can be stored in `~/.hashtree/aliases`:
37//! ```text
38//! npub1... sirius
39//! npub1... coworker
40//! ```
41//!
42//! For compatibility, public aliases in `~/.hashtree/keys` are also accepted.
43//!
44//! Then use: `htree://work/myrepo` or `htree://npub1.../myrepo`
45
46mod identity;
47mod repo_metadata;
48
49use crate::runtime::block_on_result;
50use anyhow::{Context, Result};
51use futures::{SinkExt, StreamExt};
52use hashtree_blossom::BlossomClient;
53use hashtree_core::{decode_tree_node, decrypt_chk, Cid, LinkType, TreeNode};
54use nostr_sdk::prelude::*;
55use serde::Deserialize;
56use std::collections::HashMap;
57use std::time::Duration;
58use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
59use tracing::{debug, info, warn};
60
61pub use identity::{
62    load_key_lists, load_keys, resolve_identity, resolve_self_identity, StoredKey, StoredKeyLists,
63};
64use repo_metadata::{
65    append_repo_discovery_labels, build_git_repo_list_filter, build_repo_announcement_filter,
66    build_repo_event_filter, extract_repo_announcement_euc, latest_repo_announcement_created_at,
67    latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
68    next_replaceable_created_at, pick_latest_event, pick_latest_repo_event,
69    validate_repo_publish_relays,
70};
71
72/// Event kind for hashtree roots.
73pub const KIND_HASHTREE_ROOT: u16 = 30064;
74
75/// Legacy event kind for hashtree roots originally stored as NIP-78 app data.
76pub const KIND_APP_DATA: u16 = 30078;
77
78/// NIP-34 event kinds
79pub const KIND_PULL_REQUEST: u16 = 1618;
80pub const KIND_STATUS_OPEN: u16 = 1630;
81pub const KIND_STATUS_APPLIED: u16 = 1631;
82pub const KIND_STATUS_CLOSED: u16 = 1632;
83pub const KIND_STATUS_DRAFT: u16 = 1633;
84pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
85
86pub fn hashtree_root_kinds() -> Vec<Kind> {
87    vec![
88        Kind::Custom(KIND_HASHTREE_ROOT),
89        Kind::Custom(KIND_APP_DATA),
90    ]
91}
92
93pub fn is_hashtree_root_kind(kind: Kind) -> bool {
94    kind == Kind::Custom(KIND_HASHTREE_ROOT) || kind == Kind::Custom(KIND_APP_DATA)
95}
96
97/// Label for hashtree events
98pub const LABEL_HASHTREE: &str = "hashtree";
99pub const LABEL_GIT: &str = "git";
100const IRIS_GIT_WEB_BASE_URL: &str = "https://git.iris.to/#";
101
102/// Pull request status derived from trusted NIP-34 status events.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum PullRequestState {
105    Open,
106    Applied,
107    Closed,
108    Draft,
109}
110
111impl PullRequestState {
112    pub fn as_str(self) -> &'static str {
113        match self {
114            PullRequestState::Open => "open",
115            PullRequestState::Applied => "applied",
116            PullRequestState::Closed => "closed",
117            PullRequestState::Draft => "draft",
118        }
119    }
120
121    fn from_status_kind(status_kind: u16) -> Option<Self> {
122        match status_kind {
123            KIND_STATUS_OPEN => Some(PullRequestState::Open),
124            KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
125            KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
126            KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
127            _ => None,
128        }
129    }
130
131    fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
132        status_kind
133            .and_then(Self::from_status_kind)
134            .unwrap_or(PullRequestState::Open)
135    }
136}
137
138/// Filter used when listing PRs.
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
140pub enum PullRequestStateFilter {
141    #[default]
142    Open,
143    Applied,
144    Closed,
145    Draft,
146    All,
147}
148
149impl PullRequestStateFilter {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            PullRequestStateFilter::Open => "open",
153            PullRequestStateFilter::Applied => "applied",
154            PullRequestStateFilter::Closed => "closed",
155            PullRequestStateFilter::Draft => "draft",
156            PullRequestStateFilter::All => "all",
157        }
158    }
159
160    fn includes(self, state: PullRequestState) -> bool {
161        match self {
162            PullRequestStateFilter::All => true,
163            PullRequestStateFilter::Open => state == PullRequestState::Open,
164            PullRequestStateFilter::Applied => state == PullRequestState::Applied,
165            PullRequestStateFilter::Closed => state == PullRequestState::Closed,
166            PullRequestStateFilter::Draft => state == PullRequestState::Draft,
167        }
168    }
169}
170
171/// PR metadata used by listing/filtering consumers.
172#[derive(Debug, Clone)]
173pub struct PullRequestListItem {
174    pub event_id: String,
175    pub author_pubkey: String,
176    pub state: PullRequestState,
177    pub subject: Option<String>,
178    pub commit_tip: Option<String>,
179    pub branch: Option<String>,
180    pub target_branch: Option<String>,
181    pub created_at: u64,
182}
183
184async fn fetch_events_via_raw_relay_query(
185    relays: &[String],
186    filter: Filter,
187    timeout: Duration,
188) -> Vec<Event> {
189    let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
190    let mut events_by_id = HashMap::<String, Event>::new();
191
192    for relay_url in relays {
193        let relay_events = match tokio::time::timeout(timeout, async {
194            let (mut ws, _) = connect_async(relay_url).await?;
195            ws.send(WsMessage::Text(request_json.clone())).await?;
196
197            let mut relay_events = Vec::new();
198            while let Some(message) = ws.next().await {
199                let message = message?;
200                let WsMessage::Text(text) = message else {
201                    continue;
202                };
203
204                match RelayMessage::from_json(text.as_str()) {
205                    Ok(RelayMessage::Event { event, .. }) => relay_events.push(event.into_owned()),
206                    Ok(RelayMessage::EndOfStoredEvents(_)) => break,
207                    Ok(RelayMessage::Closed { message, .. }) => {
208                        debug!("Raw relay PR query closed by {}: {}", relay_url, message);
209                        break;
210                    }
211                    Ok(_) => {}
212                    Err(err) => {
213                        debug!(
214                            "Failed to parse raw relay response from {}: {}",
215                            relay_url, err
216                        );
217                    }
218                }
219            }
220
221            let _ = ws.close(None).await;
222            Ok::<Vec<Event>, anyhow::Error>(relay_events)
223        })
224        .await
225        {
226            Ok(Ok(events)) => events,
227            Ok(Err(err)) => {
228                debug!("Raw relay PR query failed for {}: {}", relay_url, err);
229                continue;
230            }
231            Err(_) => {
232                debug!("Raw relay PR query timed out for {}", relay_url);
233                continue;
234            }
235        };
236
237        for event in relay_events {
238            events_by_id.insert(event.id.to_hex(), event);
239        }
240    }
241
242    events_by_id.into_values().collect()
243}
244
245async fn connected_relay_count(client: &Client) -> (usize, usize) {
246    let relays = client.relays().await;
247    let total = relays.len();
248    let mut connected = 0;
249    for relay in relays.values() {
250        if relay.is_connected() {
251            connected += 1;
252        }
253    }
254    (connected, total)
255}
256
257async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
258    let start = std::time::Instant::now();
259    loop {
260        if connected_relay_count(client).await.0 > 0 {
261            return true;
262        }
263        if start.elapsed() > timeout {
264            return false;
265        }
266        tokio::time::sleep(Duration::from_millis(50)).await;
267    }
268}
269
270type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
271
272#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
273enum RootResolveSource {
274    #[default]
275    Relay,
276    LocalDaemon,
277}
278
279#[derive(Debug, Clone)]
280struct ResolvedRoot {
281    root_hash: Option<String>,
282    encryption_key: Option<[u8; 32]>,
283    source: Option<RootResolveSource>,
284}
285use hashtree_config::Config;
286
287/// Result of publishing to relays
288#[derive(Debug, Clone)]
289pub struct RelayResult {
290    /// Relays that were configured
291    #[allow(dead_code)]
292    pub configured: Vec<String>,
293    /// Relays that connected
294    pub connected: Vec<String>,
295    /// Relays that failed to connect
296    pub failed: Vec<String>,
297}
298
299/// Optional NIP-34 metadata to publish alongside the htree root event.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct RepoAnnouncementOptions {
302    /// NIP-34 earliest unique commit. Usually the root commit; forks should keep the source euc.
303    pub earliest_unique_commit: Option<String>,
304    /// Mark the repo announcement as a personal fork (`["t", "personal-fork"]`).
305    pub personal_fork: bool,
306    /// Iris/htree extension for showing the exact htree source URL.
307    pub forked_from: Option<String>,
308}
309
310/// Result of uploading to blossom servers
311#[derive(Debug, Clone)]
312pub struct BlossomResult {
313    /// Servers that were configured
314    #[allow(dead_code)]
315    pub configured: Vec<String>,
316    /// Servers that accepted uploads
317    pub succeeded: Vec<String>,
318    /// Servers that failed
319    pub failed: Vec<String>,
320    /// Whether the repo tree is complete in the local hashtree store.
321    pub local_complete: bool,
322    /// Whether remote replication was incomplete.
323    pub degraded: bool,
324}
325
326/// Nostr client for git operations
327pub struct NostrClient {
328    pubkey: String,
329    /// nostr-sdk Keys for signing
330    keys: Option<Keys>,
331    relays: Vec<String>,
332    blossom: BlossomClient,
333    /// Cached refs from remote
334    cached_refs: HashMap<String, HashMap<String, String>>,
335    /// Cached root hashes (hashtree SHA256)
336    cached_root_hash: HashMap<String, String>,
337    /// Cached encryption keys
338    cached_encryption_key: HashMap<String, [u8; 32]>,
339    /// Source of the cached root hash, used to retry tentative daemon roots via relays.
340    cached_root_source: HashMap<String, RootResolveSource>,
341    /// URL secret for link-visible repos (#k=<hex>)
342    /// If set, encryption keys from nostr are XOR-masked and need unmasking
343    url_secret: Option<[u8; 32]>,
344    /// Whether this is a private (author-only) repo using NIP-44 encryption
345    is_private: bool,
346    /// Local htree daemon URL for peer-assisted root discovery
347    local_daemon_url: Option<String>,
348}
349
350#[derive(Debug, Clone, Default)]
351struct RootEventData {
352    root_hash: String,
353    encryption_key: Option<[u8; 32]>,
354    key_tag_name: Option<String>,
355    self_encrypted_ciphertext: Option<String>,
356    source: RootResolveSource,
357    daemon_source: Option<String>,
358    event_created_at: Option<u64>,
359    event_id: Option<String>,
360}
361
362#[derive(Debug, Deserialize)]
363struct DaemonResolveResponse {
364    hash: Option<String>,
365    #[serde(default)]
366    cid: Option<String>,
367    #[serde(default, rename = "key_tag")]
368    key: Option<String>,
369    #[serde(default, rename = "encryptedKey")]
370    encrypted_key: Option<String>,
371    #[serde(default, rename = "selfEncryptedKey")]
372    self_encrypted_key: Option<String>,
373    #[serde(default)]
374    source: Option<String>,
375    #[serde(default)]
376    created_at: Option<u64>,
377    #[serde(default)]
378    event_id: Option<String>,
379}
380
381impl NostrClient {
382    /// Create a new client with pubkey, optional secret key, url secret, is_private flag, and config
383    pub fn new(
384        pubkey: &str,
385        secret_key: Option<String>,
386        url_secret: Option<[u8; 32]>,
387        is_private: bool,
388        config: &Config,
389    ) -> Result<Self> {
390        // Ensure rustls has a process-wide crypto provider even when used as a library (tests).
391        let _ = rustls::crypto::ring::default_provider().install_default();
392
393        // Use provided secret, or try environment variable
394        let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
395
396        // Create nostr-sdk Keys if we have a secret
397        let keys = if let Some(ref secret_hex) = secret_key {
398            let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
399            let secret = nostr::SecretKey::from_slice(&secret_bytes)
400                .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
401            Some(Keys::new(secret))
402        } else {
403            None
404        };
405
406        // Create BlossomClient (needs keys for upload auth) from the resolved
407        // config passed by the helper instead of reloading defaults from disk.
408        let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
409        let mut read_servers = config.blossom.all_read_servers();
410        if let Some(local_url) =
411            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
412        {
413            if !read_servers.iter().any(|server| server == &local_url) {
414                read_servers.insert(0, local_url);
415            }
416        }
417        let blossom = BlossomClient::new_empty(blossom_keys)
418            .with_read_servers(read_servers)
419            .with_write_servers(config.blossom.all_write_servers())
420            .with_timeout(Duration::from_secs(120));
421
422        tracing::info!(
423            "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
424            blossom.read_servers(),
425            blossom.write_servers()
426        );
427
428        let relays = hashtree_config::resolve_relays(
429            &config.nostr.relays,
430            Some(config.server.bind_address.as_str()),
431        );
432        let local_daemon_url =
433            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
434                .or_else(|| {
435                    config
436                        .blossom
437                        .read_servers
438                        .iter()
439                        .find(|url| {
440                            url.starts_with("http://127.0.0.1:")
441                                || url.starts_with("http://localhost:")
442                        })
443                        .cloned()
444                });
445
446        Ok(Self {
447            pubkey: pubkey.to_string(),
448            keys,
449            relays,
450            blossom,
451            cached_refs: HashMap::new(),
452            cached_root_hash: HashMap::new(),
453            cached_encryption_key: HashMap::new(),
454            cached_root_source: HashMap::new(),
455            url_secret,
456            is_private,
457            local_daemon_url,
458        })
459    }
460
461    fn format_repo_author(pubkey_hex: &str) -> String {
462        PublicKey::from_hex(pubkey_hex)
463            .ok()
464            .and_then(|pk| pk.to_bech32().ok())
465            .unwrap_or_else(|| pubkey_hex.to_string())
466    }
467
468    fn daemon_pubkey_identifier(&self) -> String {
469        Self::format_repo_author(&self.pubkey)
470    }
471
472    /// Check if we can sign (have secret key for this pubkey)
473    #[allow(dead_code)]
474    pub fn can_sign(&self) -> bool {
475        self.keys.is_some()
476    }
477
478    pub fn list_repos(&self) -> Result<Vec<String>> {
479        block_on_result(self.list_repos_async())
480    }
481
482    pub async fn list_repos_async(&self) -> Result<Vec<String>> {
483        let client = Client::default();
484
485        for relay in &self.relays {
486            if let Err(e) = client.add_relay(relay).await {
487                warn!("Failed to add relay {}: {}", relay, e);
488            }
489        }
490        client.connect().await;
491
492        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
493            let _ = client.disconnect().await;
494            return Err(anyhow::anyhow!(
495                "Failed to connect to any relay while listing repos"
496            ));
497        }
498
499        let author = PublicKey::from_hex(&self.pubkey)
500            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
501        let filter = build_git_repo_list_filter(author);
502
503        let events = match tokio::time::timeout(
504            Duration::from_secs(3),
505            client.fetch_events(filter, Duration::from_secs(3)),
506        )
507        .await
508        {
509            Ok(Ok(events)) => events,
510            Ok(Err(e)) => {
511                let _ = client.disconnect().await;
512                return Err(anyhow::anyhow!(
513                    "Failed to fetch git repo events from relays: {}",
514                    e
515                ));
516            }
517            Err(_) => {
518                let _ = client.disconnect().await;
519                return Err(anyhow::anyhow!(
520                    "Timed out fetching git repo events from relays"
521                ));
522            }
523        };
524
525        let _ = client.disconnect().await;
526        let events = events.to_vec();
527
528        Ok(list_git_repo_announcements(&events)
529            .into_iter()
530            .map(|repo| repo.repo_name)
531            .collect())
532    }
533
534    /// Fetch refs for a repository from nostr
535    /// Returns refs parsed from the hashtree at the root hash
536    pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
537        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
538        Ok(refs)
539    }
540
541    /// Fetch refs with a quick timeout (3s) for push operations
542    /// Returns empty if timeout - allows push to proceed
543    #[allow(dead_code)]
544    pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
545        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
546        Ok(refs)
547    }
548
549    /// Fetch refs and root hash info from nostr
550    /// Returns (refs, root_hash, encryption_key)
551    #[allow(dead_code)]
552    pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
553        self.fetch_refs_with_timeout(repo_name, 10)
554    }
555
556    pub(crate) fn refetch_refs_without_local_daemon(
557        &mut self,
558        repo_name: &str,
559        timeout_secs: u64,
560    ) -> Result<FetchedRefs> {
561        self.clear_cached_remote_state(repo_name);
562        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
563    }
564
565    fn clear_cached_remote_state(&mut self, repo_name: &str) {
566        self.cached_refs.remove(repo_name);
567        self.cached_root_hash.remove(repo_name);
568        self.cached_encryption_key.remove(repo_name);
569        self.cached_root_source.remove(repo_name);
570    }
571
572    fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
573        if let Some(ref root) = resolved.root_hash {
574            self.cached_root_hash
575                .insert(repo_name.to_string(), root.clone());
576        } else {
577            self.cached_root_hash.remove(repo_name);
578        }
579
580        if let Some(key) = resolved.encryption_key {
581            self.cached_encryption_key
582                .insert(repo_name.to_string(), key);
583        } else {
584            self.cached_encryption_key.remove(repo_name);
585        }
586
587        if let Some(source) = resolved.source {
588            self.cached_root_source
589                .insert(repo_name.to_string(), source);
590        } else {
591            self.cached_root_source.remove(repo_name);
592        }
593    }
594
595    /// Fetch refs with configurable timeout
596    fn fetch_refs_with_timeout(
597        &mut self,
598        repo_name: &str,
599        timeout_secs: u64,
600    ) -> Result<FetchedRefs> {
601        debug!(
602            "Fetching refs for {} from {} (timeout {}s)",
603            repo_name, self.pubkey, timeout_secs
604        );
605
606        // Check cache first
607        if let Some(refs) = self.cached_refs.get(repo_name) {
608            let root = self.cached_root_hash.get(repo_name).cloned();
609            let key = self.cached_encryption_key.get(repo_name).cloned();
610            return Ok((refs.clone(), root, key));
611        }
612
613        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
614    }
615
616    fn fetch_refs_with_timeout_uncached(
617        &mut self,
618        repo_name: &str,
619        timeout_secs: u64,
620        allow_local_daemon: bool,
621    ) -> Result<FetchedRefs> {
622        let resolved = block_on_result(self.resolve_root_async_with_timeout(
623            repo_name,
624            timeout_secs,
625            allow_local_daemon,
626        ))?;
627
628        match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
629            Ok(fetched) => Ok(fetched),
630            Err(err)
631                if resolved.source == Some(RootResolveSource::LocalDaemon)
632                    && allow_local_daemon =>
633            {
634                warn!(
635                    "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
636                    repo_name, err
637                );
638                self.clear_cached_remote_state(repo_name);
639                self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
640            }
641            Err(err) => Err(err),
642        }
643    }
644
645    fn fetch_refs_for_resolved_root(
646        &mut self,
647        repo_name: &str,
648        resolved: &ResolvedRoot,
649    ) -> Result<FetchedRefs> {
650        if resolved.source != Some(RootResolveSource::LocalDaemon) {
651            self.cache_resolved_root(repo_name, resolved);
652        }
653
654        let refs = if let Some(ref root) = resolved.root_hash {
655            block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
656        } else {
657            HashMap::new()
658        };
659
660        self.cache_resolved_root(repo_name, resolved);
661        self.cached_refs.insert(repo_name.to_string(), refs.clone());
662        Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
663    }
664
665    fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
666        let root_hash = event
667            .tags
668            .iter()
669            .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
670            .map(|t| t.as_slice()[1].to_string())
671            .unwrap_or_else(|| event.content.to_string());
672
673        let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
674            .tags
675            .iter()
676            .find_map(|t| {
677                let slice = t.as_slice();
678                if slice.len() < 2 {
679                    return None;
680                }
681                let tag_name = slice[0].as_str();
682                let tag_value = slice[1].to_string();
683                if tag_name == "selfEncryptedKey" {
684                    return Some((None, Some(tag_name.to_string()), Some(tag_value)));
685                }
686                if tag_name == "key" || tag_name == "encryptedKey" {
687                    if let Ok(bytes) = hex::decode(&tag_value) {
688                        if bytes.len() == 32 {
689                            let mut key = [0u8; 32];
690                            key.copy_from_slice(&bytes);
691                            return Some((Some(key), Some(tag_name.to_string()), None));
692                        }
693                    }
694                }
695                None
696            })
697            .unwrap_or((None, None, None));
698
699        RootEventData {
700            root_hash,
701            encryption_key,
702            key_tag_name,
703            self_encrypted_ciphertext,
704            source: RootResolveSource::Relay,
705            daemon_source: None,
706            event_created_at: Some(event.created_at.as_secs()),
707            event_id: Some(event.id.to_hex()),
708        }
709    }
710
711    fn parse_daemon_response_to_root_data(
712        response: DaemonResolveResponse,
713    ) -> Option<RootEventData> {
714        let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
715        let daemon_source = response.source.clone();
716        let event_created_at = response.created_at;
717        let event_id = response.event_id.clone();
718        let root_hash = response
719            .hash
720            .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
721        if root_hash.is_empty() {
722            return None;
723        }
724
725        let mut data = RootEventData {
726            root_hash,
727            encryption_key: parsed_cid.and_then(|cid| cid.key),
728            key_tag_name: None,
729            self_encrypted_ciphertext: None,
730            source: RootResolveSource::LocalDaemon,
731            daemon_source,
732            event_created_at,
733            event_id,
734        };
735
736        if let Some(ciphertext) = response.self_encrypted_key {
737            data.key_tag_name = Some("selfEncryptedKey".to_string());
738            data.self_encrypted_ciphertext = Some(ciphertext);
739            return Some(data);
740        }
741
742        let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
743            ("encryptedKey", v)
744        } else if let Some(v) = response.key {
745            ("key", v)
746        } else {
747            return Some(data);
748        };
749
750        if let Ok(bytes) = hex::decode(&tag_value) {
751            if bytes.len() == 32 {
752                let mut key = [0u8; 32];
753                key.copy_from_slice(&bytes);
754                data.encryption_key = Some(key);
755                data.key_tag_name = Some(tag_name.to_string());
756            }
757        }
758
759        Some(data)
760    }
761
762    async fn fetch_root_from_local_daemon(
763        &self,
764        repo_name: &str,
765        timeout: Duration,
766    ) -> Option<RootEventData> {
767        let base = self.local_daemon_url.as_ref()?;
768        let pubkey = self.daemon_pubkey_identifier();
769        let url = format!(
770            "{}/api/nostr/resolve/{}/{}?refresh=1",
771            base.trim_end_matches('/'),
772            pubkey,
773            repo_name
774        );
775
776        let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
777        let response = client.get(&url).send().await.ok()?;
778        if !response.status().is_success() {
779            return None;
780        }
781
782        let payload: DaemonResolveResponse = response.json().await.ok()?;
783        let source = payload
784            .source
785            .clone()
786            .unwrap_or_else(|| "unknown".to_string());
787        let parsed = Self::parse_daemon_response_to_root_data(payload)?;
788        debug!(
789            "Resolved repo {} via local daemon source={}",
790            repo_name, source
791        );
792        Some(parsed)
793    }
794
795    fn daemon_root_needs_relay_confirmation(data: &RootEventData) -> bool {
796        if data.source != RootResolveSource::LocalDaemon {
797            return false;
798        }
799
800        !matches!(data.daemon_source.as_deref(), Some("nostr-relay" | "nostr"))
801    }
802
803    fn root_event_is_newer(left: &RootEventData, right: &RootEventData) -> bool {
804        match (left.event_created_at, right.event_created_at) {
805            (Some(left_time), Some(right_time)) => {
806                (left_time, left.event_id.as_deref().unwrap_or(""))
807                    > (right_time, right.event_id.as_deref().unwrap_or(""))
808            }
809            (Some(_), None) => true,
810            _ => false,
811        }
812    }
813
814    fn choose_newer_root_data(fallback: RootEventData, relay: RootEventData) -> RootEventData {
815        if Self::root_event_is_newer(&fallback, &relay) {
816            fallback
817        } else {
818            relay
819        }
820    }
821
822    async fn cache_public_root_in_local_daemon(
823        &self,
824        repo_name: &str,
825        root_hash: &str,
826        encryption_key: Option<(&[u8; 32], bool, bool)>,
827    ) {
828        let Some(base) = self.local_daemon_url.as_ref() else {
829            return;
830        };
831
832        match encryption_key {
833            Some((_key, false, false)) => {}
834            Some((_key, _is_link_visible, _is_self_private)) => return,
835            None => {}
836        }
837
838        let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
839        let pubkey = self.daemon_pubkey_identifier();
840        let payload = serde_json::json!({
841            "npub": pubkey,
842            "treeName": repo_name,
843            "hash": root_hash,
844            "visibility": "public",
845        });
846
847        let client = match reqwest::Client::builder()
848            .timeout(Duration::from_secs(2))
849            .build()
850        {
851            Ok(client) => client,
852            Err(err) => {
853                debug!("Could not build local daemon cache client: {}", err);
854                return;
855            }
856        };
857
858        match client.post(url).json(&payload).send().await {
859            Ok(response) if response.status().is_success() => {
860                debug!("Cached repo root for {} in local daemon", repo_name);
861            }
862            Ok(response) => {
863                debug!(
864                    "Local daemon root cache returned status {} for {}",
865                    response.status(),
866                    repo_name
867                );
868            }
869            Err(err) => {
870                debug!("Could not cache repo root in local daemon: {}", err);
871            }
872        }
873    }
874
875    async fn resolve_root_async_with_timeout(
876        &self,
877        repo_name: &str,
878        timeout_secs: u64,
879        allow_local_daemon: bool,
880    ) -> Result<ResolvedRoot> {
881        // Create nostr-sdk client
882        let client = Client::default();
883
884        // Add relays
885        for relay in &self.relays {
886            if let Err(e) = client.add_relay(relay).await {
887                warn!("Failed to add relay {}: {}", relay, e);
888            }
889        }
890
891        // Connect to relays - this starts async connection
892        client.connect().await;
893
894        let connect_timeout = Duration::from_secs(2);
895        let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
896        let local_daemon_timeout = Duration::from_secs(4);
897        let retry_delay = Duration::from_millis(300);
898        let max_attempts = 2;
899
900        let start = std::time::Instant::now();
901
902        // Build filter for hashtree root events from this author with matching d-tag
903        let author = PublicKey::from_hex(&self.pubkey)
904            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
905
906        let filter = build_repo_event_filter(author, repo_name);
907
908        debug!("Querying relays for repo {} events", repo_name);
909
910        let mut root_data = None;
911        let mut daemon_fallback: Option<RootEventData> = None;
912        for attempt in 1..=max_attempts {
913            if allow_local_daemon {
914                if let Some(data) = self
915                    .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
916                    .await
917                {
918                    if Self::daemon_root_needs_relay_confirmation(&data) {
919                        debug!(
920                            "Local daemon resolved {} via {}; checking relays for fresher root",
921                            repo_name,
922                            data.daemon_source.as_deref().unwrap_or("unknown")
923                        );
924                        daemon_fallback = Some(data);
925                    } else {
926                        root_data = Some(data);
927                        break;
928                    }
929                }
930            }
931
932            if !allow_local_daemon && attempt == 1 {
933                debug!(
934                    "Skipping local daemon while resolving {} because relay retry was requested",
935                    repo_name
936                );
937            }
938
939            if allow_local_daemon && daemon_fallback.is_none() {
940                debug!(
941                    "Local daemon did not resolve {}; querying relays",
942                    repo_name
943                );
944            }
945
946            // Wait for at least one relay to connect (quick timeout - break immediately when one
947            // connects). We retry once because relays and the local daemon can both lag briefly.
948            let connect_start = std::time::Instant::now();
949            let mut last_log = std::time::Instant::now();
950            let mut has_connected_relay = false;
951            loop {
952                let (connected, total) = connected_relay_count(&client).await;
953                if connected > 0 {
954                    debug!(
955                        "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
956                        connected,
957                        total,
958                        start.elapsed(),
959                        attempt,
960                        max_attempts
961                    );
962                    has_connected_relay = true;
963                    break;
964                }
965                if last_log.elapsed() > Duration::from_millis(500) {
966                    debug!(
967                        "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
968                        total,
969                        start.elapsed(),
970                        attempt,
971                        max_attempts
972                    );
973                    last_log = std::time::Instant::now();
974                }
975                if connect_start.elapsed() > connect_timeout {
976                    debug!(
977                        "Timeout waiting for relay connections - continuing with local-daemon fallback"
978                    );
979                    break;
980                }
981                tokio::time::sleep(Duration::from_millis(50)).await;
982            }
983
984            // Query with relay-level timeout.
985            // Using `EventSource::relays(Some(...))` preserves partial results from responsive
986            // relays instead of discarding everything when one relay stalls.
987            if !has_connected_relay {
988                if let Some(data) = daemon_fallback.take() {
989                    debug!(
990                        "Using local daemon root for {} because no relay connected",
991                        repo_name
992                    );
993                    root_data = Some(data);
994                    break;
995                }
996            }
997
998            let events = if has_connected_relay {
999                match client.fetch_events(filter.clone(), query_timeout).await {
1000                    Ok(events) => events.to_vec(),
1001                    Err(e) => {
1002                        warn!("Failed to fetch events: {}", e);
1003                        vec![]
1004                    }
1005                }
1006            } else {
1007                vec![]
1008            };
1009
1010            debug!(
1011                "Got {} events from relays on attempt {}/{}",
1012                events.len(),
1013                attempt,
1014                max_attempts
1015            );
1016            let relay_event = pick_latest_repo_event(events.iter(), repo_name);
1017
1018            if let Some(event) = relay_event {
1019                debug!(
1020                    "Found relay event with root hash: {}",
1021                    &event.content[..12.min(event.content.len())]
1022                );
1023                let relay_data = Self::parse_root_event_data_from_event(event);
1024                root_data = Some(match daemon_fallback.take() {
1025                    Some(fallback) => Self::choose_newer_root_data(fallback, relay_data),
1026                    None => relay_data,
1027                });
1028                break;
1029            }
1030
1031            if attempt < max_attempts {
1032                debug!(
1033                    "No relay hashtree event found for {} on attempt {}/{}; retrying",
1034                    repo_name, attempt, max_attempts
1035                );
1036                tokio::time::sleep(retry_delay).await;
1037            } else if let Some(data) = daemon_fallback.take() {
1038                debug!(
1039                    "Using local daemon root for {} after relay lookup returned no event",
1040                    repo_name
1041                );
1042                root_data = Some(data);
1043                break;
1044            }
1045        }
1046
1047        // Disconnect
1048        let _ = client.disconnect().await;
1049
1050        let root_data = match root_data {
1051            Some(data) => data,
1052            None => {
1053                anyhow::bail!(
1054                    "Repository '{}' not found (no hashtree event published by {})",
1055                    repo_name,
1056                    Self::format_repo_author(&self.pubkey)
1057                );
1058            }
1059        };
1060
1061        let root_hash = root_data.root_hash;
1062
1063        if root_hash.is_empty() {
1064            debug!("Empty root hash in event");
1065            return Ok(ResolvedRoot {
1066                root_hash: None,
1067                encryption_key: None,
1068                source: None,
1069            });
1070        }
1071
1072        let encryption_key = root_data.encryption_key;
1073        let key_tag_name = root_data.key_tag_name;
1074        let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
1075        let root_source = root_data.source;
1076
1077        // Process encryption key based on tag type
1078        let unmasked_key = match key_tag_name.as_deref() {
1079            Some("encryptedKey") => {
1080                // Link-visible: XOR the masked key with url_secret
1081                if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
1082                    let mut unmasked = [0u8; 32];
1083                    for i in 0..32 {
1084                        unmasked[i] = masked[i] ^ secret[i];
1085                    }
1086                    Some(unmasked)
1087                } else {
1088                    anyhow::bail!(
1089                        "This repo is link-visible and requires a secret key.\n\
1090                         Use: htree://.../{repo_name}#k=<secret>\n\
1091                         Ask the repo owner for the full URL with the secret."
1092                    );
1093                }
1094            }
1095            Some("selfEncryptedKey") => {
1096                // Private: only decrypt if #private is in the URL
1097                if !self.is_private {
1098                    anyhow::bail!(
1099                        "This repo is private (author-only).\n\
1100                         Use: htree://.../{repo_name}#private\n\
1101                         Only the author can access this repo."
1102                    );
1103                }
1104
1105                // Decrypt with NIP-44 using our secret key
1106                if let Some(keys) = &self.keys {
1107                    if let Some(ciphertext) = self_encrypted_ciphertext {
1108                        // Decrypt with NIP-44 (encrypted to self)
1109                        let pubkey = keys.public_key();
1110                        match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1111                            Ok(key_hex) => {
1112                                let key_bytes =
1113                                    hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1114                                if key_bytes.len() != 32 {
1115                                    anyhow::bail!("Decrypted key wrong length");
1116                                }
1117                                let mut key = [0u8; 32];
1118                                key.copy_from_slice(&key_bytes);
1119                                Some(key)
1120                            }
1121                            Err(e) => {
1122                                anyhow::bail!(
1123                                    "Failed to decrypt private repo: {}\n\
1124                                     The repo may be corrupted or published with a different key.",
1125                                    e
1126                                );
1127                            }
1128                        }
1129                    } else {
1130                        anyhow::bail!("selfEncryptedKey tag has invalid format");
1131                    }
1132                } else {
1133                    anyhow::bail!(
1134                        "Cannot access this private repo.\n\
1135                         Private repos can only be accessed by their author.\n\
1136                         You don't have the secret key for this repo's owner."
1137                    );
1138                }
1139            }
1140            Some("key") | None => {
1141                // Public: use key directly
1142                encryption_key
1143            }
1144            Some(other) => {
1145                warn!("Unknown key tag type: {}", other);
1146                encryption_key
1147            }
1148        };
1149
1150        info!(
1151            "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1152            &root_hash[..12.min(root_hash.len())],
1153            repo_name,
1154            unmasked_key.is_some(),
1155            self.url_secret.is_some()
1156        );
1157
1158        Ok(ResolvedRoot {
1159            root_hash: Some(root_hash),
1160            encryption_key: unmasked_key,
1161            source: Some(root_source),
1162        })
1163    }
1164
1165    /// Decrypt data if encryption key is provided, then decode as tree node
1166    fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1167        let decrypted_data: Vec<u8>;
1168        let data_to_decode = if let Some(k) = key {
1169            decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1170            &decrypted_data
1171        } else {
1172            data
1173        };
1174
1175        decode_tree_node(data_to_decode).context("Failed to decode tree node")
1176    }
1177
1178    /// Fetch git refs from hashtree structure
1179    /// Structure: root -> .git/ -> refs/ -> heads/main -> <sha>
1180    async fn fetch_refs_from_hashtree(
1181        &self,
1182        root_hash: &str,
1183        encryption_key: Option<&[u8; 32]>,
1184    ) -> Result<HashMap<String, String>> {
1185        let mut refs = HashMap::new();
1186        debug!(
1187            "fetch_refs_from_hashtree: downloading root {}",
1188            &root_hash[..12]
1189        );
1190
1191        // Download root directory from Blossom - propagate errors properly
1192        let root_data = match self.blossom.download(root_hash).await {
1193            Ok(data) => {
1194                debug!("Downloaded {} bytes from blossom", data.len());
1195                data
1196            }
1197            Err(e) => {
1198                anyhow::bail!(
1199                    "Failed to download root hash {}: {}",
1200                    &root_hash[..12.min(root_hash.len())],
1201                    e
1202                );
1203            }
1204        };
1205
1206        // Parse root as directory node (decrypt if needed)
1207        let root_node = self
1208            .decrypt_and_decode(&root_data, encryption_key)
1209            .with_context(|| {
1210                format!(
1211                    "Failed to decode root node {} (encrypted: {})",
1212                    &root_hash[..12.min(root_hash.len())],
1213                    encryption_key.is_some()
1214                )
1215            })?;
1216        debug!("Decoded root node with {} links", root_node.links.len());
1217
1218        // Find .git directory
1219        debug!(
1220            "Root links: {:?}",
1221            root_node
1222                .links
1223                .iter()
1224                .map(|l| l.name.as_deref())
1225                .collect::<Vec<_>>()
1226        );
1227        let git_link = root_node
1228            .links
1229            .iter()
1230            .find(|l| l.name.as_deref() == Some(".git"));
1231        let (git_hash, git_key) = match git_link {
1232            Some(link) => {
1233                debug!("Found .git link with key: {}", link.key.is_some());
1234                (hex::encode(link.hash), link.key)
1235            }
1236            None => {
1237                debug!("No .git directory in hashtree root");
1238                return Ok(refs);
1239            }
1240        };
1241
1242        // Download .git directory
1243        let git_data = match self.blossom.download(&git_hash).await {
1244            Ok(data) => data,
1245            Err(e) => {
1246                anyhow::bail!(
1247                    "Failed to download .git directory ({}): {}",
1248                    &git_hash[..12],
1249                    e
1250                );
1251            }
1252        };
1253
1254        let git_node = self
1255            .decrypt_and_decode(&git_data, git_key.as_ref())
1256            .with_context(|| {
1257                format!(
1258                    "Failed to decode .git directory {} (encrypted: {})",
1259                    &git_hash[..12.min(git_hash.len())],
1260                    git_key.is_some()
1261                )
1262            })?;
1263        debug!(
1264            "Decoded .git node with {} links: {:?}",
1265            git_node.links.len(),
1266            git_node
1267                .links
1268                .iter()
1269                .map(|l| l.name.as_deref())
1270                .collect::<Vec<_>>()
1271        );
1272
1273        // Find refs directory
1274        let refs_link = git_node
1275            .links
1276            .iter()
1277            .find(|l| l.name.as_deref() == Some("refs"));
1278        let (refs_hash, refs_key) = match refs_link {
1279            Some(link) => (hex::encode(link.hash), link.key),
1280            None => {
1281                debug!("No refs directory in .git");
1282                return Ok(refs);
1283            }
1284        };
1285
1286        // Download refs directory
1287        let refs_data =
1288            self.blossom.download(&refs_hash).await.with_context(|| {
1289                format!("Failed to download refs directory {}", &refs_hash[..12])
1290            })?;
1291
1292        let refs_node = self
1293            .decrypt_and_decode(&refs_data, refs_key.as_ref())
1294            .with_context(|| {
1295                format!(
1296                    "Failed to decode refs directory {} (encrypted: {})",
1297                    &refs_hash[..12.min(refs_hash.len())],
1298                    refs_key.is_some()
1299                )
1300            })?;
1301
1302        // Look for HEAD in .git directory
1303        if let Some(head_link) = git_node
1304            .links
1305            .iter()
1306            .find(|l| l.name.as_deref() == Some("HEAD"))
1307        {
1308            let head_hash = hex::encode(head_link.hash);
1309            let head_data = self
1310                .blossom
1311                .download(&head_hash)
1312                .await
1313                .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1314            // HEAD is a blob, decrypt if needed
1315            let head_content = if let Some(k) = head_link.key.as_ref() {
1316                let decrypted = decrypt_chk(&head_data, k)
1317                    .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1318                String::from_utf8_lossy(&decrypted).trim().to_string()
1319            } else {
1320                String::from_utf8_lossy(&head_data).trim().to_string()
1321            };
1322            refs.insert("HEAD".to_string(), head_content);
1323        }
1324
1325        // Recursively walk refs/ subdirectories (heads, tags, etc.)
1326        for subdir_link in &refs_node.links {
1327            if subdir_link.link_type != LinkType::Dir {
1328                continue;
1329            }
1330            let subdir_name = match &subdir_link.name {
1331                Some(n) => n.clone(),
1332                None => continue,
1333            };
1334            let subdir_hash = hex::encode(subdir_link.hash);
1335
1336            self.collect_refs_recursive(
1337                &subdir_hash,
1338                subdir_link.key.as_ref(),
1339                &format!("refs/{}", subdir_name),
1340                &mut refs,
1341            )
1342            .await?;
1343        }
1344
1345        debug!("Found {} refs from hashtree", refs.len());
1346        Ok(refs)
1347    }
1348
1349    /// Recursively collect refs from a directory
1350    async fn collect_refs_recursive(
1351        &self,
1352        dir_hash: &str,
1353        dir_key: Option<&[u8; 32]>,
1354        prefix: &str,
1355        refs: &mut HashMap<String, String>,
1356    ) -> Result<()> {
1357        let dir_data = self
1358            .blossom
1359            .download(dir_hash)
1360            .await
1361            .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1362
1363        let dir_node = self
1364            .decrypt_and_decode(&dir_data, dir_key)
1365            .with_context(|| {
1366                format!(
1367                    "Failed to decode refs subtree {} (encrypted: {})",
1368                    &dir_hash[..12.min(dir_hash.len())],
1369                    dir_key.is_some()
1370                )
1371            })?;
1372
1373        for link in &dir_node.links {
1374            let name = match &link.name {
1375                Some(n) => n.clone(),
1376                None => continue,
1377            };
1378            let link_hash = hex::encode(link.hash);
1379            let ref_path = format!("{}/{}", prefix, name);
1380
1381            if link.link_type == LinkType::Dir {
1382                // Recurse into subdirectory
1383                Box::pin(self.collect_refs_recursive(
1384                    &link_hash,
1385                    link.key.as_ref(),
1386                    &ref_path,
1387                    refs,
1388                ))
1389                .await?;
1390            } else {
1391                // This is a ref file - read the SHA
1392                let ref_data = self
1393                    .blossom
1394                    .download(&link_hash)
1395                    .await
1396                    .with_context(|| format!("Failed to download ref {}", ref_path))?;
1397                // Decrypt if needed
1398                let sha = if let Some(k) = link.key.as_ref() {
1399                    let decrypted = decrypt_chk(&ref_data, k)
1400                        .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1401                    String::from_utf8_lossy(&decrypted).trim().to_string()
1402                } else {
1403                    String::from_utf8_lossy(&ref_data).trim().to_string()
1404                };
1405                if !sha.is_empty() {
1406                    debug!("Found ref {} -> {}", ref_path, sha);
1407                    refs.insert(ref_path, sha);
1408                }
1409            }
1410        }
1411
1412        Ok(())
1413    }
1414
1415    /// Update a ref in local cache (will be published with publish_repo)
1416    #[allow(dead_code)]
1417    pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1418        info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1419
1420        let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1421        refs.insert(ref_name.to_string(), sha.to_string());
1422
1423        Ok(())
1424    }
1425
1426    /// Delete a ref from local cache
1427    pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1428        info!("Deleting ref {} for {}", ref_name, repo_name);
1429
1430        if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1431            refs.remove(ref_name);
1432        }
1433
1434        Ok(())
1435    }
1436
1437    /// Get cached root hash for a repository
1438    pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1439        self.cached_root_hash.get(repo_name)
1440    }
1441
1442    /// Get cached encryption key for a repository
1443    pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1444        self.cached_encryption_key.get(repo_name)
1445    }
1446
1447    pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1448        self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1449    }
1450
1451    /// Get the Blossom client for direct downloads
1452    pub fn blossom(&self) -> &BlossomClient {
1453        &self.blossom
1454    }
1455
1456    /// Get the configured relay URLs
1457    pub fn relay_urls(&self) -> Vec<String> {
1458        self.relays.clone()
1459    }
1460
1461    /// Get the public key (hex)
1462    #[allow(dead_code)]
1463    pub fn pubkey(&self) -> &str {
1464        &self.pubkey
1465    }
1466
1467    /// Get the public key as npub bech32
1468    pub fn npub(&self) -> String {
1469        PublicKey::from_hex(&self.pubkey)
1470            .ok()
1471            .and_then(|pk| pk.to_bech32().ok())
1472            .unwrap_or_else(|| self.pubkey.clone())
1473    }
1474
1475    /// Publish repository to nostr as kind 30064 event
1476    /// Format:
1477    ///   kind: 30064
1478    ///   tags: [["d", repo_name], ["l", "hashtree"], ["hash", root_hash], ["key"|"encryptedKey", encryption_key]]
1479    ///   content: <merkle-root-hash>
1480    /// Returns: (npub URL, relay result with connected/failed details)
1481    /// If is_private is true, uses "encryptedKey" tag (XOR masked); otherwise uses "key" tag (plaintext CHK)
1482    pub fn publish_repo(
1483        &self,
1484        repo_name: &str,
1485        root_hash: &str,
1486        encryption_key: Option<(&[u8; 32], bool, bool)>,
1487    ) -> Result<(String, RelayResult)> {
1488        self.publish_repo_with_announcement(repo_name, root_hash, encryption_key, None)
1489    }
1490
1491    pub fn publish_repo_with_announcement(
1492        &self,
1493        repo_name: &str,
1494        root_hash: &str,
1495        encryption_key: Option<(&[u8; 32], bool, bool)>,
1496        repo_announcement: Option<RepoAnnouncementOptions>,
1497    ) -> Result<(String, RelayResult)> {
1498        let keys = self.keys.as_ref().context(format!(
1499            "Cannot push: no secret key for {}. You can only push to your own repos.",
1500            &self.pubkey[..16]
1501        ))?;
1502
1503        info!(
1504            "Publishing repo {} with root hash {} (encrypted: {})",
1505            repo_name,
1506            root_hash,
1507            encryption_key.is_some()
1508        );
1509
1510        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
1511        block_on_result(self.publish_repo_async(
1512            keys,
1513            repo_name,
1514            root_hash,
1515            encryption_key,
1516            repo_announcement,
1517        ))
1518    }
1519
1520    async fn publish_repo_async(
1521        &self,
1522        keys: &Keys,
1523        repo_name: &str,
1524        root_hash: &str,
1525        encryption_key: Option<(&[u8; 32], bool, bool)>,
1526        repo_announcement: Option<RepoAnnouncementOptions>,
1527    ) -> Result<(String, RelayResult)> {
1528        // Create nostr-sdk client with our keys
1529        let client = Client::new(keys.clone());
1530
1531        let configured: Vec<String> = self.relays.clone();
1532        let mut connected: Vec<String> = Vec::new();
1533        let mut failed: Vec<String> = Vec::new();
1534
1535        // Add relays
1536        for relay in &self.relays {
1537            if let Err(e) = client.add_relay(relay).await {
1538                warn!("Failed to add relay {}: {}", relay, e);
1539                failed.push(relay.clone());
1540            }
1541        }
1542
1543        // Connect to relays - this starts async connection in background
1544        client.connect().await;
1545
1546        // Wait for at least one relay to connect (same pattern as fetch)
1547        let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1548
1549        let publish_created_at = next_replaceable_created_at(
1550            Timestamp::now(),
1551            latest_repo_event_created_at(
1552                &client,
1553                keys.public_key(),
1554                repo_name,
1555                Duration::from_secs(2),
1556            )
1557            .await,
1558        );
1559
1560        // Build event with tags
1561        let mut tags = vec![
1562            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1563            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1564            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1565        ];
1566
1567        // Add encryption key if present (required for decryption)
1568        // Key modes:
1569        // - selfEncryptedKey: NIP-44 encrypted to self (author-only private)
1570        // - encryptedKey: XOR masked with URL secret (link-visible)
1571        // - key: plaintext CHK (public)
1572        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1573            if is_self_private {
1574                // NIP-44 encrypt to self
1575                let pubkey = keys.public_key();
1576                let key_hex = hex::encode(key);
1577                let encrypted =
1578                    nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1579                        .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1580                tags.push(Tag::custom(
1581                    TagKind::custom("selfEncryptedKey"),
1582                    vec![encrypted],
1583                ));
1584            } else if is_link_visible {
1585                // XOR masked key
1586                tags.push(Tag::custom(
1587                    TagKind::custom("encryptedKey"),
1588                    vec![hex::encode(key)],
1589                ));
1590            } else {
1591                // Public: plaintext CHK
1592                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1593            }
1594        }
1595
1596        append_repo_discovery_labels(&mut tags, repo_name);
1597
1598        // Sign the event
1599        let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), root_hash)
1600            .tags(tags)
1601            .custom_created_at(publish_created_at)
1602            .sign_with_keys(keys)
1603            .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1604
1605        // Send event to connected relays
1606        match client.send_event(&event).await {
1607            Ok(output) => {
1608                // Track which relays confirmed
1609                for url in output.success.iter() {
1610                    let url_str = url.to_string();
1611                    if !connected.contains(&url_str) {
1612                        connected.push(url_str);
1613                    }
1614                }
1615                // Only mark as failed if we got explicit rejection
1616                for (url, _err) in output.failed.iter() {
1617                    let url_str = url.to_string();
1618                    if !failed.contains(&url_str) && !connected.contains(&url_str) {
1619                        failed.push(url_str);
1620                    }
1621                }
1622                info!(
1623                    "Sent event {} to {} relays ({} failed)",
1624                    output.id(),
1625                    output.success.len(),
1626                    output.failed.len()
1627                );
1628            }
1629            Err(e) => {
1630                warn!("Failed to send event: {}", e);
1631                // Mark all as failed
1632                for relay in &self.relays {
1633                    if !failed.contains(relay) {
1634                        failed.push(relay.clone());
1635                    }
1636                }
1637            }
1638        };
1639
1640        // Build the full htree:// URL with npub
1641        let npub_url = keys
1642            .public_key()
1643            .to_bech32()
1644            .map(|npub| format!("htree://{}/{}", npub, repo_name))
1645            .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1646
1647        let relay_validation = validate_repo_publish_relays(&configured, &connected);
1648
1649        if relay_validation.is_ok() {
1650            if let Some(repo_announcement) = repo_announcement.as_ref() {
1651                if let Err(err) = self
1652                    .publish_repo_announcement_async(
1653                        &client,
1654                        keys,
1655                        repo_name,
1656                        &npub_url,
1657                        repo_announcement,
1658                    )
1659                    .await
1660                {
1661                    warn!("Failed to publish NIP-34 repo announcement: {}", err);
1662                }
1663            }
1664        }
1665
1666        // Disconnect and give time for cleanup
1667        let _ = client.disconnect().await;
1668        tokio::time::sleep(Duration::from_millis(50)).await;
1669
1670        relay_validation?;
1671
1672        self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1673            .await;
1674
1675        Ok((
1676            npub_url,
1677            RelayResult {
1678                configured,
1679                connected,
1680                failed,
1681            },
1682        ))
1683    }
1684
1685    fn build_repo_announcement_tags(
1686        repo_name: &str,
1687        clone_url: &str,
1688        relays: &[String],
1689        options: &RepoAnnouncementOptions,
1690    ) -> Vec<Tag> {
1691        let mut tags = vec![
1692            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1693            Tag::custom(TagKind::custom("name"), vec![repo_name.to_string()]),
1694            Tag::custom(TagKind::custom("clone"), vec![clone_url.to_string()]),
1695        ];
1696
1697        if let Some(web_url) = Self::iris_git_web_url_for_htree_clone(clone_url) {
1698            tags.push(Tag::custom(TagKind::custom("web"), vec![web_url]));
1699        }
1700
1701        if !relays.is_empty() {
1702            tags.push(Tag::custom(TagKind::custom("relays"), relays.to_vec()));
1703        }
1704
1705        if let Some(euc) = options
1706            .earliest_unique_commit
1707            .as_deref()
1708            .map(str::trim)
1709            .filter(|value| !value.is_empty())
1710        {
1711            tags.push(Tag::custom(
1712                TagKind::custom("r"),
1713                vec![euc.to_string(), "euc".to_string()],
1714            ));
1715        }
1716
1717        if options.personal_fork {
1718            tags.push(Tag::custom(
1719                TagKind::custom("t"),
1720                vec!["personal-fork".to_string()],
1721            ));
1722        }
1723
1724        if let Some(forked_from) = options
1725            .forked_from
1726            .as_deref()
1727            .map(str::trim)
1728            .filter(|value| !value.is_empty())
1729        {
1730            tags.push(Tag::custom(
1731                TagKind::custom("forked-from"),
1732                vec![forked_from.to_string()],
1733            ));
1734        }
1735
1736        tags
1737    }
1738
1739    fn iris_git_web_url_for_htree_clone(clone_url: &str) -> Option<String> {
1740        let raw = clone_url.strip_prefix("htree://")?;
1741        let path = raw.split('#').next().unwrap_or(raw);
1742        let (owner, repo_path) = path.split_once('/')?;
1743        if !owner.starts_with("npub1") || repo_path.is_empty() {
1744            return None;
1745        }
1746
1747        let path = std::iter::once(owner)
1748            .chain(repo_path.split('/').filter(|segment| !segment.is_empty()))
1749            .map(Self::percent_encode_path_segment)
1750            .collect::<Vec<_>>()
1751            .join("/");
1752
1753        Some(format!("{IRIS_GIT_WEB_BASE_URL}/{path}"))
1754    }
1755
1756    fn percent_encode_path_segment(segment: &str) -> String {
1757        let mut encoded = String::with_capacity(segment.len());
1758        for byte in segment.bytes() {
1759            match byte {
1760                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1761                    encoded.push(byte as char)
1762                }
1763                _ => encoded.push_str(&format!("%{byte:02X}")),
1764            }
1765        }
1766        encoded
1767    }
1768
1769    async fn publish_repo_announcement_async(
1770        &self,
1771        client: &Client,
1772        keys: &Keys,
1773        repo_name: &str,
1774        clone_url: &str,
1775        options: &RepoAnnouncementOptions,
1776    ) -> Result<()> {
1777        let created_at = next_replaceable_created_at(
1778            Timestamp::now(),
1779            latest_repo_announcement_created_at(
1780                client,
1781                keys.public_key(),
1782                repo_name,
1783                Duration::from_secs(2),
1784            )
1785            .await,
1786        );
1787        let tags = Self::build_repo_announcement_tags(repo_name, clone_url, &self.relays, options);
1788        let event = EventBuilder::new(Kind::Custom(KIND_REPO_ANNOUNCEMENT), "")
1789            .tags(tags)
1790            .custom_created_at(created_at)
1791            .sign_with_keys(keys)
1792            .map_err(|e| anyhow::anyhow!("Failed to sign NIP-34 repo announcement: {}", e))?;
1793
1794        let output = client
1795            .send_event(&event)
1796            .await
1797            .map_err(|e| anyhow::anyhow!("Failed to publish NIP-34 repo announcement: {}", e))?;
1798        if output.success.is_empty() {
1799            anyhow::bail!("NIP-34 repo announcement was not confirmed by any relay");
1800        }
1801
1802        info!(
1803            "Published NIP-34 repo announcement {} to {} relays",
1804            output.id(),
1805            output.success.len()
1806        );
1807        Ok(())
1808    }
1809
1810    pub fn fetch_repo_announcement_euc(
1811        &self,
1812        owner_pubkey_hex: &str,
1813        repo_name: &str,
1814    ) -> Result<Option<String>> {
1815        block_on_result(self.fetch_repo_announcement_euc_async(owner_pubkey_hex, repo_name))
1816    }
1817
1818    async fn fetch_repo_announcement_euc_async(
1819        &self,
1820        owner_pubkey_hex: &str,
1821        repo_name: &str,
1822    ) -> Result<Option<String>> {
1823        let owner = PublicKey::from_hex(owner_pubkey_hex)
1824            .map_err(|e| anyhow::anyhow!("Invalid repo owner pubkey: {}", e))?;
1825        let client = Client::default();
1826
1827        for relay in &self.relays {
1828            if let Err(e) = client.add_relay(relay).await {
1829                debug!(
1830                    "Failed to add relay {} for repo announcement lookup: {}",
1831                    relay, e
1832                );
1833            }
1834        }
1835        client.connect().await;
1836
1837        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1838            let _ = client.disconnect().await;
1839            return Ok(None);
1840        }
1841
1842        let filter = build_repo_announcement_filter(owner, repo_name);
1843        let events = match tokio::time::timeout(
1844            Duration::from_secs(3),
1845            client.fetch_events(filter, Duration::from_secs(3)),
1846        )
1847        .await
1848        {
1849            Ok(Ok(events)) => events.to_vec(),
1850            Ok(Err(err)) => {
1851                debug!(
1852                    "Failed to fetch NIP-34 repo announcement for {}: {}",
1853                    repo_name, err
1854                );
1855                Vec::new()
1856            }
1857            Err(_) => {
1858                debug!(
1859                    "Timed out fetching NIP-34 repo announcement for {}",
1860                    repo_name
1861                );
1862                Vec::new()
1863            }
1864        };
1865
1866        let _ = client.disconnect().await;
1867        Ok(pick_latest_event(events.iter()).and_then(extract_repo_announcement_euc))
1868    }
1869
1870    /// Fetch pull requests targeting this repo, filtered by state.
1871    pub fn fetch_prs(
1872        &self,
1873        repo_name: &str,
1874        state_filter: PullRequestStateFilter,
1875    ) -> Result<Vec<PullRequestListItem>> {
1876        block_on_result(self.fetch_prs_async(repo_name, state_filter))
1877    }
1878
1879    pub async fn fetch_prs_async(
1880        &self,
1881        repo_name: &str,
1882        state_filter: PullRequestStateFilter,
1883    ) -> Result<Vec<PullRequestListItem>> {
1884        let client = Client::default();
1885
1886        for relay in &self.relays {
1887            if let Err(e) = client.add_relay(relay).await {
1888                warn!("Failed to add relay {}: {}", relay, e);
1889            }
1890        }
1891        client.connect().await;
1892
1893        // Wait for at least one relay (quick timeout)
1894        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1895            let _ = client.disconnect().await;
1896            return Err(anyhow::anyhow!(
1897                "Failed to connect to any relay while fetching PRs"
1898            ));
1899        }
1900
1901        // Query for kind 1618 PRs targeting this repo
1902        let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1903        let pull_request_filter = Filter::new()
1904            .kind(Kind::Custom(KIND_PULL_REQUEST))
1905            .custom_tag(
1906                SingleLetterTag::lowercase(Alphabet::A),
1907                repo_address.clone(),
1908            );
1909
1910        let mut pr_events = match tokio::time::timeout(
1911            Duration::from_secs(3),
1912            client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
1913        )
1914        .await
1915        {
1916            Ok(Ok(events)) => events.to_vec(),
1917            Ok(Err(e)) => {
1918                let _ = client.disconnect().await;
1919                return Err(anyhow::anyhow!(
1920                    "Failed to fetch PR events from relays: {}",
1921                    e
1922                ));
1923            }
1924            Err(_) => {
1925                let _ = client.disconnect().await;
1926                return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
1927            }
1928        };
1929
1930        if pr_events.is_empty() {
1931            let fallback_events = fetch_events_via_raw_relay_query(
1932                &self.relays,
1933                pull_request_filter,
1934                Duration::from_secs(3),
1935            )
1936            .await;
1937            if !fallback_events.is_empty() {
1938                debug!(
1939                    "Raw relay fallback recovered {} PR event(s) for {}",
1940                    fallback_events.len(),
1941                    repo_name
1942                );
1943                pr_events = fallback_events;
1944            }
1945        }
1946
1947        if pr_events.is_empty() {
1948            let _ = client.disconnect().await;
1949            return Ok(Vec::new());
1950        }
1951
1952        // Collect PR event IDs for status query
1953        let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1954
1955        // Query for status events referencing these PRs
1956        let status_event_filter = Filter::new()
1957            .kinds(vec![
1958                Kind::Custom(KIND_STATUS_OPEN),
1959                Kind::Custom(KIND_STATUS_APPLIED),
1960                Kind::Custom(KIND_STATUS_CLOSED),
1961                Kind::Custom(KIND_STATUS_DRAFT),
1962            ])
1963            .custom_tags(
1964                SingleLetterTag::lowercase(Alphabet::E),
1965                pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1966            );
1967
1968        let mut status_events = match tokio::time::timeout(
1969            Duration::from_secs(3),
1970            client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
1971        )
1972        .await
1973        {
1974            Ok(Ok(events)) => events.to_vec(),
1975            Ok(Err(e)) => {
1976                let _ = client.disconnect().await;
1977                return Err(anyhow::anyhow!(
1978                    "Failed to fetch PR status events from relays: {}",
1979                    e
1980                ));
1981            }
1982            Err(_) => {
1983                let _ = client.disconnect().await;
1984                return Err(anyhow::anyhow!(
1985                    "Timed out fetching PR status events from relays"
1986                ));
1987            }
1988        };
1989
1990        if status_events.is_empty() {
1991            let fallback_events = fetch_events_via_raw_relay_query(
1992                &self.relays,
1993                status_event_filter,
1994                Duration::from_secs(3),
1995            )
1996            .await;
1997            if !fallback_events.is_empty() {
1998                debug!(
1999                    "Raw relay fallback recovered {} PR status event(s) for {}",
2000                    fallback_events.len(),
2001                    repo_name
2002                );
2003                status_events = fallback_events;
2004            }
2005        }
2006
2007        let _ = client.disconnect().await;
2008
2009        // Build map: pr_event_id -> latest trusted status kind
2010        let latest_status =
2011            latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
2012
2013        let mut prs = Vec::new();
2014        for event in &pr_events {
2015            let pr_id = event.id.to_hex();
2016            let state =
2017                PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
2018            if !state_filter.includes(state) {
2019                continue;
2020            }
2021
2022            let mut subject = None;
2023            let mut commit_tip = None;
2024            let mut branch = None;
2025            let mut target_branch = None;
2026
2027            for tag in event.tags.iter() {
2028                let slice = tag.as_slice();
2029                if slice.len() >= 2 {
2030                    match slice[0].as_str() {
2031                        "subject" => subject = Some(slice[1].to_string()),
2032                        "c" => commit_tip = Some(slice[1].to_string()),
2033                        "branch" => branch = Some(slice[1].to_string()),
2034                        "target-branch" => target_branch = Some(slice[1].to_string()),
2035                        _ => {}
2036                    }
2037                }
2038            }
2039
2040            prs.push(PullRequestListItem {
2041                event_id: pr_id,
2042                author_pubkey: event.pubkey.to_hex(),
2043                state,
2044                subject,
2045                commit_tip,
2046                branch,
2047                target_branch,
2048                created_at: event.created_at.as_secs(),
2049            });
2050        }
2051
2052        // Newest first; tie-break by event id for deterministic output.
2053        prs.sort_by(|left, right| {
2054            right
2055                .created_at
2056                .cmp(&left.created_at)
2057                .then_with(|| right.event_id.cmp(&left.event_id))
2058        });
2059
2060        debug!(
2061            "Found {} PRs for {} (filter: {:?})",
2062            prs.len(),
2063            repo_name,
2064            state_filter
2065        );
2066        Ok(prs)
2067    }
2068
2069    /// Publish a kind 1631 (STATUS_APPLIED) event to mark a PR as merged
2070    pub fn publish_pr_merged_status(
2071        &self,
2072        pr_event_id: &str,
2073        pr_author_pubkey: &str,
2074    ) -> Result<()> {
2075        let keys = self
2076            .keys
2077            .as_ref()
2078            .context("Cannot publish status: no secret key")?;
2079
2080        block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
2081    }
2082
2083    async fn publish_pr_merged_status_async(
2084        &self,
2085        keys: &Keys,
2086        pr_event_id: &str,
2087        pr_author_pubkey: &str,
2088    ) -> Result<()> {
2089        let client = Client::new(keys.clone());
2090
2091        for relay in &self.relays {
2092            if let Err(e) = client.add_relay(relay).await {
2093                warn!("Failed to add relay {}: {}", relay, e);
2094            }
2095        }
2096        client.connect().await;
2097
2098        // Wait for at least one relay
2099        if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
2100            anyhow::bail!("Failed to connect to any relay for status publish");
2101        }
2102
2103        let tags = vec![
2104            Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
2105            Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
2106        ];
2107
2108        let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
2109            .tags(tags)
2110            .sign_with_keys(keys)
2111            .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
2112
2113        let publish_result = match client.send_event(&event).await {
2114            Ok(output) => {
2115                if output.success.is_empty() {
2116                    Err(anyhow::anyhow!(
2117                        "PR merged status was not confirmed by any relay"
2118                    ))
2119                } else {
2120                    info!(
2121                        "Published PR merged status to {} relays",
2122                        output.success.len()
2123                    );
2124                    Ok(())
2125                }
2126            }
2127            Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
2128        };
2129
2130        let _ = client.disconnect().await;
2131        tokio::time::sleep(Duration::from_millis(50)).await;
2132        publish_result
2133    }
2134
2135    /// Upload blob to blossom server
2136    #[allow(dead_code)]
2137    pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
2138        let hash = self
2139            .blossom
2140            .upload(data)
2141            .await
2142            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
2143        Ok(hash)
2144    }
2145
2146    /// Upload blob only if it doesn't exist
2147    #[allow(dead_code)]
2148    pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
2149        self.blossom
2150            .upload_if_missing(data)
2151            .await
2152            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
2153    }
2154
2155    /// Download blob from blossom server
2156    #[allow(dead_code)]
2157    pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
2158        self.blossom
2159            .download(hash)
2160            .await
2161            .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
2162    }
2163
2164    /// Try to download blob, returns None if not found
2165    #[allow(dead_code)]
2166    pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
2167        self.blossom.try_download(hash).await
2168    }
2169}
2170
2171#[cfg(test)]
2172mod tests;