Skip to main content

git_remote_htree/
nostr_client.rs

1//! Nostr client for publishing and fetching git repository references
2//!
3//! Uses kind 30078 (application-specific data) with hashtree structure:
4//! {
5//!   "kind": 30078,
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//! ## Secret file format
21//!
22//! The secrets file (~/.hashtree/keys) supports multiple keys with optional petnames:
23//! ```text
24//! nsec1... default
25//! nsec1... work
26//! nsec1... personal
27//! ```
28//!
29//! Or hex format:
30//! ```text
31//! <64-char-hex> default
32//! <64-char-hex> work
33//! ```
34//!
35//! Then use: `htree://work/myrepo` or `htree://npub1.../myrepo`
36
37use anyhow::{Context, Result};
38use hashtree_blossom::BlossomClient;
39use hashtree_core::{decode_tree_node, decrypt_chk, LinkType};
40use nostr_sdk::prelude::*;
41use std::collections::HashMap;
42use std::time::Duration;
43use tracing::{debug, info, warn};
44
45/// Event kind for application-specific data (NIP-78)
46pub const KIND_APP_DATA: u16 = 30078;
47
48/// NIP-34 event kinds
49pub const KIND_PULL_REQUEST: u16 = 1618;
50pub const KIND_STATUS_OPEN: u16 = 1630;
51pub const KIND_STATUS_APPLIED: u16 = 1631;
52pub const KIND_STATUS_CLOSED: u16 = 1632;
53pub const KIND_STATUS_DRAFT: u16 = 1633;
54pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
55
56/// Label for hashtree events
57pub const LABEL_HASHTREE: &str = "hashtree";
58
59/// An open pull request from Nostr
60#[derive(Debug, Clone)]
61pub struct OpenPullRequest {
62    pub event_id: String,
63    pub author_pubkey: String,
64    pub commit_tip: Option<String>,
65    pub branch: Option<String>,
66    pub target_branch: Option<String>,
67}
68
69/// A stored key with optional petname
70#[derive(Debug, Clone)]
71pub struct StoredKey {
72    /// Secret key in hex format
73    pub secret_hex: String,
74    /// Public key in hex format
75    pub pubkey_hex: String,
76    /// Optional petname (e.g., "default", "work")
77    pub petname: Option<String>,
78}
79
80impl StoredKey {
81    /// Create from secret key hex, deriving pubkey
82    pub fn from_secret_hex(secret_hex: &str, petname: Option<String>) -> Result<Self> {
83        use secp256k1::{Secp256k1, SecretKey};
84
85        let sk_bytes = hex::decode(secret_hex).context("Invalid hex in secret key")?;
86        let sk = SecretKey::from_slice(&sk_bytes).context("Invalid secret key")?;
87        let secp = Secp256k1::new();
88        let pk = sk.x_only_public_key(&secp).0;
89        let pubkey_hex = hex::encode(pk.serialize());
90
91        Ok(Self {
92            secret_hex: secret_hex.to_string(),
93            pubkey_hex,
94            petname,
95        })
96    }
97
98    /// Create from nsec bech32 format
99    pub fn from_nsec(nsec: &str, petname: Option<String>) -> Result<Self> {
100        let secret_key =
101            SecretKey::parse(nsec).map_err(|e| anyhow::anyhow!("Invalid nsec format: {}", e))?;
102        let secret_hex = hex::encode(secret_key.to_secret_bytes());
103        Self::from_secret_hex(&secret_hex, petname)
104    }
105}
106
107/// Load all keys from config files
108pub fn load_keys() -> Vec<StoredKey> {
109    let mut keys = Vec::new();
110
111    // Primary: ~/.hashtree/keys (multi-key format)
112    let keys_path = hashtree_config::get_keys_path();
113    if let Ok(content) = std::fs::read_to_string(&keys_path) {
114        for entry in hashtree_config::parse_keys_file(&content) {
115            let key = if entry.secret.starts_with("nsec1") {
116                StoredKey::from_nsec(&entry.secret, entry.alias)
117            } else if entry.secret.len() == 64 {
118                StoredKey::from_secret_hex(&entry.secret, entry.alias)
119            } else {
120                continue;
121            };
122
123            if let Ok(k) = key {
124                debug!(
125                    "Loaded key: pubkey={}, petname={:?}",
126                    k.pubkey_hex, k.petname
127                );
128                keys.push(k);
129            }
130        }
131    }
132
133    keys
134}
135
136/// Resolve an identifier to (pubkey_hex, secret_hex)
137/// Identifier can be:
138/// - "self" (uses default key, auto-generates if needed)
139/// - petname (e.g., "work", "default")
140/// - pubkey hex (64 chars)
141/// - npub bech32
142pub fn resolve_identity(identifier: &str) -> Result<(String, Option<String>)> {
143    let keys = load_keys();
144
145    // Special "self" alias - use default key or first available, auto-generate if none
146    if identifier == "self" {
147        // First try to find a key with "self" petname
148        if let Some(key) = keys.iter().find(|k| k.petname.as_deref() == Some("self")) {
149            return Ok((key.pubkey_hex.clone(), Some(key.secret_hex.clone())));
150        }
151        // Then try "default"
152        if let Some(key) = keys
153            .iter()
154            .find(|k| k.petname.as_deref() == Some("default"))
155        {
156            return Ok((key.pubkey_hex.clone(), Some(key.secret_hex.clone())));
157        }
158        // Then use first available key
159        if let Some(key) = keys.first() {
160            return Ok((key.pubkey_hex.clone(), Some(key.secret_hex.clone())));
161        }
162        // No keys - auto-generate one with "self" petname
163        let new_key = generate_and_save_key("self")?;
164        info!("Generated new identity: npub1{}", &new_key.pubkey_hex[..12]);
165        return Ok((new_key.pubkey_hex, Some(new_key.secret_hex)));
166    }
167
168    // Check if it's a petname
169    for key in &keys {
170        if key.petname.as_deref() == Some(identifier) {
171            return Ok((key.pubkey_hex.clone(), Some(key.secret_hex.clone())));
172        }
173    }
174
175    // Check if it's an npub
176    if identifier.starts_with("npub1") {
177        let pk = PublicKey::parse(identifier)
178            .map_err(|e| anyhow::anyhow!("Invalid npub format: {}", e))?;
179        let pubkey_hex = hex::encode(pk.to_bytes());
180
181        // Check if we have the secret for this pubkey
182        let secret = keys
183            .iter()
184            .find(|k| k.pubkey_hex == pubkey_hex)
185            .map(|k| k.secret_hex.clone());
186
187        return Ok((pubkey_hex, secret));
188    }
189
190    // Check if it's a hex pubkey
191    if identifier.len() == 64 && hex::decode(identifier).is_ok() {
192        let secret = keys
193            .iter()
194            .find(|k| k.pubkey_hex == identifier)
195            .map(|k| k.secret_hex.clone());
196
197        return Ok((identifier.to_string(), secret));
198    }
199
200    // Unknown identifier - might be a petname we don't have
201    anyhow::bail!(
202        "Unknown identity '{}'. Add it to ~/.hashtree/keys or use a pubkey/npub.",
203        identifier
204    )
205}
206
207/// Generate a new key and save it to ~/.hashtree/keys with the given petname
208fn generate_and_save_key(petname: &str) -> Result<StoredKey> {
209    use std::fs::{self, OpenOptions};
210    use std::io::Write;
211
212    // Generate new key
213    let keys = nostr_sdk::Keys::generate();
214    let secret_hex = hex::encode(keys.secret_key().to_secret_bytes());
215    let pubkey_hex = hex::encode(keys.public_key().to_bytes());
216
217    // Ensure directory exists
218    let keys_path = hashtree_config::get_keys_path();
219    if let Some(parent) = keys_path.parent() {
220        fs::create_dir_all(parent)?;
221    }
222
223    // Append to keys file
224    let mut file = OpenOptions::new()
225        .create(true)
226        .append(true)
227        .open(&keys_path)?;
228
229    // Write as nsec with petname
230    let nsec = keys
231        .secret_key()
232        .to_bech32()
233        .map_err(|e| anyhow::anyhow!("Failed to encode nsec: {}", e))?;
234    writeln!(file, "{} {}", nsec, petname)?;
235
236    info!(
237        "Saved new key to {:?} with petname '{}'",
238        keys_path, petname
239    );
240
241    Ok(StoredKey {
242        secret_hex,
243        pubkey_hex,
244        petname: Some(petname.to_string()),
245    })
246}
247
248use hashtree_config::Config;
249
250fn pick_latest_event<'a, I>(events: I) -> Option<&'a Event>
251where
252    I: IntoIterator<Item = &'a Event>,
253{
254    // Use NIP-16 replaceable event ordering: created_at, then event id.
255    events
256        .into_iter()
257        .max_by_key(|event| (event.created_at, event.id))
258}
259
260fn latest_trusted_pr_status_kinds(
261    pr_events: &[Event],
262    status_events: &[Event],
263    repo_owner_pubkey: &str,
264) -> HashMap<String, u16> {
265    let pr_authors: HashMap<String, String> = pr_events
266        .iter()
267        .map(|event| (event.id.to_hex(), event.pubkey.to_hex()))
268        .collect();
269
270    let mut trusted_statuses: HashMap<String, Vec<&Event>> = HashMap::new();
271    for status in status_events {
272        let signer_pubkey = status.pubkey.to_hex();
273        for tag in status.tags.iter() {
274            let slice = tag.as_slice();
275            if slice.len() < 2 || slice[0].as_str() != "e" {
276                continue;
277            }
278
279            let pr_id = slice[1].to_string();
280            let Some(pr_author_pubkey) = pr_authors.get(&pr_id) else {
281                continue;
282            };
283
284            let trusted = if status.kind.as_u16() == KIND_STATUS_APPLIED {
285                // Only the repository owner can mark a PR as applied/merged.
286                signer_pubkey == repo_owner_pubkey
287            } else {
288                signer_pubkey == *pr_author_pubkey || signer_pubkey == repo_owner_pubkey
289            };
290            if trusted {
291                trusted_statuses.entry(pr_id).or_default().push(status);
292            }
293        }
294    }
295
296    let mut latest_status = HashMap::new();
297    for (pr_id, events) in trusted_statuses {
298        // Treat maintainer-applied as terminal for open-PR computation so later
299        // author statuses cannot make an already-merged PR appear open again.
300        if let Some(applied) = pick_latest_event(
301            events
302                .iter()
303                .copied()
304                .filter(|event| event.kind.as_u16() == KIND_STATUS_APPLIED),
305        ) {
306            latest_status.insert(pr_id, applied.kind.as_u16());
307        } else if let Some(latest) = pick_latest_event(events.iter().copied()) {
308            latest_status.insert(pr_id, latest.kind.as_u16());
309        }
310    }
311
312    latest_status
313}
314
315/// Result of publishing to relays
316#[derive(Debug, Clone)]
317pub struct RelayResult {
318    /// Relays that were configured
319    #[allow(dead_code)]
320    pub configured: Vec<String>,
321    /// Relays that connected
322    pub connected: Vec<String>,
323    /// Relays that failed to connect
324    pub failed: Vec<String>,
325}
326
327/// Result of uploading to blossom servers
328#[derive(Debug, Clone)]
329pub struct BlossomResult {
330    /// Servers that were configured
331    #[allow(dead_code)]
332    pub configured: Vec<String>,
333    /// Servers that accepted uploads
334    pub succeeded: Vec<String>,
335    /// Servers that failed
336    pub failed: Vec<String>,
337}
338
339/// Nostr client for git operations
340pub struct NostrClient {
341    pubkey: String,
342    /// nostr-sdk Keys for signing
343    keys: Option<Keys>,
344    relays: Vec<String>,
345    blossom: BlossomClient,
346    /// Cached refs from remote
347    cached_refs: HashMap<String, HashMap<String, String>>,
348    /// Cached root hashes (hashtree SHA256)
349    cached_root_hash: HashMap<String, String>,
350    /// Cached encryption keys
351    cached_encryption_key: HashMap<String, [u8; 32]>,
352    /// URL secret for link-visible repos (#k=<hex>)
353    /// If set, encryption keys from nostr are XOR-masked and need unmasking
354    url_secret: Option<[u8; 32]>,
355    /// Whether this is a private (author-only) repo using NIP-44 encryption
356    is_private: bool,
357}
358
359impl NostrClient {
360    /// Create a new client with pubkey, optional secret key, url secret, is_private flag, and config
361    pub fn new(
362        pubkey: &str,
363        secret_key: Option<String>,
364        url_secret: Option<[u8; 32]>,
365        is_private: bool,
366        config: &Config,
367    ) -> Result<Self> {
368        // Use provided secret, or try environment variable
369        let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
370
371        // Create nostr-sdk Keys if we have a secret
372        let keys = if let Some(ref secret_hex) = secret_key {
373            let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
374            let secret = nostr::SecretKey::from_slice(&secret_bytes)
375                .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
376            Some(Keys::new(secret))
377        } else {
378            None
379        };
380
381        // Create BlossomClient (needs keys for upload auth)
382        // BlossomClient auto-loads servers from config
383        let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
384        let blossom = BlossomClient::new(blossom_keys).with_timeout(Duration::from_secs(30));
385
386        tracing::info!(
387            "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
388            blossom.read_servers(),
389            blossom.write_servers()
390        );
391
392        let relays = hashtree_config::resolve_relays(
393            &config.nostr.relays,
394            Some(config.server.bind_address.as_str()),
395        );
396
397        Ok(Self {
398            pubkey: pubkey.to_string(),
399            keys,
400            relays,
401            blossom,
402            cached_refs: HashMap::new(),
403            cached_root_hash: HashMap::new(),
404            cached_encryption_key: HashMap::new(),
405            url_secret,
406            is_private,
407        })
408    }
409
410    /// Check if we can sign (have secret key for this pubkey)
411    #[allow(dead_code)]
412    pub fn can_sign(&self) -> bool {
413        self.keys.is_some()
414    }
415
416    /// Fetch refs for a repository from nostr
417    /// Returns refs parsed from the hashtree at the root hash
418    pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
419        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
420        Ok(refs)
421    }
422
423    /// Fetch refs with a quick timeout (3s) for push operations
424    /// Returns empty if timeout - allows push to proceed
425    #[allow(dead_code)]
426    pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
427        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
428        Ok(refs)
429    }
430
431    /// Fetch refs and root hash info from nostr
432    /// Returns (refs, root_hash, encryption_key)
433    #[allow(dead_code)]
434    pub fn fetch_refs_with_root(
435        &mut self,
436        repo_name: &str,
437    ) -> Result<(HashMap<String, String>, Option<String>, Option<[u8; 32]>)> {
438        self.fetch_refs_with_timeout(repo_name, 10)
439    }
440
441    /// Fetch refs with configurable timeout
442    fn fetch_refs_with_timeout(
443        &mut self,
444        repo_name: &str,
445        timeout_secs: u64,
446    ) -> Result<(HashMap<String, String>, Option<String>, Option<[u8; 32]>)> {
447        debug!(
448            "Fetching refs for {} from {} (timeout {}s)",
449            repo_name, self.pubkey, timeout_secs
450        );
451
452        // Check cache first
453        if let Some(refs) = self.cached_refs.get(repo_name) {
454            let root = self.cached_root_hash.get(repo_name).cloned();
455            let key = self.cached_encryption_key.get(repo_name).cloned();
456            return Ok((refs.clone(), root, key));
457        }
458
459        // Query relays for kind 30078 events
460        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
461        let rt = tokio::runtime::Builder::new_multi_thread()
462            .enable_all()
463            .build()
464            .context("Failed to create tokio runtime")?;
465
466        let (refs, root_hash, encryption_key) =
467            rt.block_on(self.fetch_refs_async_with_timeout(repo_name, timeout_secs))?;
468        self.cached_refs.insert(repo_name.to_string(), refs.clone());
469        if let Some(ref root) = root_hash {
470            self.cached_root_hash
471                .insert(repo_name.to_string(), root.clone());
472        }
473        if let Some(key) = encryption_key {
474            self.cached_encryption_key
475                .insert(repo_name.to_string(), key);
476        }
477        Ok((refs, root_hash, encryption_key))
478    }
479
480    async fn fetch_refs_async_with_timeout(
481        &self,
482        repo_name: &str,
483        timeout_secs: u64,
484    ) -> Result<(HashMap<String, String>, Option<String>, Option<[u8; 32]>)> {
485        // Create nostr-sdk client
486        let client = Client::default();
487
488        // Add relays
489        for relay in &self.relays {
490            if let Err(e) = client.add_relay(relay).await {
491                warn!("Failed to add relay {}: {}", relay, e);
492            }
493        }
494
495        // Connect to relays - this starts async connection
496        client.connect().await;
497
498        // Wait for at least one relay to connect (quick timeout - break immediately when one connects)
499        // Use shorter connect timeout (2s max) since we only need 1 relay
500        let connect_timeout = Duration::from_secs(2);
501        let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
502
503        let start = std::time::Instant::now();
504        let mut last_log = std::time::Instant::now();
505        loop {
506            let relays = client.relays().await;
507            let total = relays.len();
508            let mut connected = 0;
509            for relay in relays.values() {
510                if relay.is_connected().await {
511                    connected += 1;
512                }
513            }
514            if connected > 0 {
515                debug!(
516                    "Connected to {}/{} relay(s) in {:?}",
517                    connected,
518                    total,
519                    start.elapsed()
520                );
521                break;
522            }
523            // Log progress every 500ms so user knows something is happening
524            if last_log.elapsed() > Duration::from_millis(500) {
525                debug!(
526                    "Connecting to relays... (0/{} after {:?})",
527                    total,
528                    start.elapsed()
529                );
530                last_log = std::time::Instant::now();
531            }
532            if start.elapsed() > connect_timeout {
533                debug!("Timeout waiting for relay connections - treating as empty repo");
534                let _ = client.disconnect().await;
535                return Ok((HashMap::new(), None, None));
536            }
537            tokio::time::sleep(Duration::from_millis(50)).await;
538        }
539
540        // Build filter for kind 30078 events from this author with matching d-tag
541        let author = PublicKey::from_hex(&self.pubkey)
542            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
543
544        let filter = Filter::new()
545            .kind(Kind::Custom(KIND_APP_DATA))
546            .author(author)
547            .custom_tag(SingleLetterTag::lowercase(Alphabet::D), vec![repo_name])
548            .custom_tag(
549                SingleLetterTag::lowercase(Alphabet::L),
550                vec![LABEL_HASHTREE],
551            )
552            .limit(50);
553
554        debug!("Querying relays for repo {} events", repo_name);
555
556        // Query with timeout - treat timeout as "no events found" for new repos
557        let events = match tokio::time::timeout(
558            query_timeout,
559            client.get_events_of(vec![filter], EventSource::relays(None)),
560        )
561        .await
562        {
563            Ok(Ok(events)) => events,
564            Ok(Err(e)) => {
565                warn!("Failed to fetch events: {}", e);
566                vec![]
567            }
568            Err(_) => {
569                debug!("Relay query timed out - treating as empty repo");
570                vec![]
571            }
572        };
573
574        // Disconnect
575        let _ = client.disconnect().await;
576
577        // Find the most recent event with "hashtree" label
578        debug!("Got {} events from relays", events.len());
579        let event = pick_latest_event(events.iter().filter(|e| {
580            e.tags.iter().any(|t| {
581                t.as_slice().len() >= 2
582                    && t.as_slice()[0].as_str() == "l"
583                    && t.as_slice()[1].as_str() == LABEL_HASHTREE
584            })
585        }));
586
587        let Some(event) = event else {
588            let npub = PublicKey::from_hex(&self.pubkey)
589                .map(|pk| {
590                    pk.to_bech32()
591                        .unwrap_or_else(|_| self.pubkey[..12].to_string())
592                })
593                .map(|s| format!("{}...{}", &s[..12], &s[s.len() - 6..]))
594                .unwrap_or_else(|_| self.pubkey[..12].to_string());
595            anyhow::bail!(
596                "Repository '{}' not found (no hashtree event published by {})",
597                repo_name,
598                npub
599            );
600        };
601        debug!(
602            "Found event with root hash: {}",
603            &event.content[..12.min(event.content.len())]
604        );
605
606        // Get root hash from content or "hash" tag
607        let root_hash = event
608            .tags
609            .iter()
610            .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
611            .map(|t| t.as_slice()[1].to_string())
612            .unwrap_or_else(|| event.content.to_string());
613
614        if root_hash.is_empty() {
615            debug!("Empty root hash in event");
616            return Ok((HashMap::new(), None, None));
617        }
618
619        // Get encryption key and determine visibility type from tag name
620        // - "key": public repo, key is plaintext CHK (hex)
621        // - "encryptedKey": link-visible repo, key is XOR-masked (hex)
622        // - "selfEncryptedKey": private repo, key is NIP-44 ciphertext (base64)
623        let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
624            .tags
625            .iter()
626            .find_map(|t| {
627                let slice = t.as_slice();
628                if slice.len() >= 2 {
629                    let tag_name = slice[0].as_str();
630                    let tag_value = slice[1].to_string();
631
632                    if tag_name == "selfEncryptedKey" {
633                        // NIP-44 ciphertext - don't parse as hex, save for later decryption
634                        return Some((None, Some(tag_name.to_string()), Some(tag_value)));
635                    } else if tag_name == "key" || tag_name == "encryptedKey" {
636                        // Hex-encoded key
637                        if let Ok(bytes) = hex::decode(&tag_value) {
638                            if bytes.len() == 32 {
639                                let mut key = [0u8; 32];
640                                key.copy_from_slice(&bytes);
641                                return Some((Some(key), Some(tag_name.to_string()), None));
642                            }
643                        }
644                    }
645                }
646                None
647            })
648            .unwrap_or((None, None, None));
649
650        // Process encryption key based on tag type
651        let unmasked_key = match key_tag_name.as_deref() {
652            Some("encryptedKey") => {
653                // Link-visible: XOR the masked key with url_secret
654                if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
655                    let mut unmasked = [0u8; 32];
656                    for i in 0..32 {
657                        unmasked[i] = masked[i] ^ secret[i];
658                    }
659                    Some(unmasked)
660                } else {
661                    anyhow::bail!(
662                        "This repo is link-visible and requires a secret key.\n\
663                         Use: htree://.../{repo_name}#k=<secret>\n\
664                         Ask the repo owner for the full URL with the secret."
665                    );
666                }
667            }
668            Some("selfEncryptedKey") => {
669                // Private: only decrypt if #private is in the URL
670                if !self.is_private {
671                    anyhow::bail!(
672                        "This repo is private (author-only).\n\
673                         Use: htree://.../{repo_name}#private\n\
674                         Only the author can access this repo."
675                    );
676                }
677
678                // Decrypt with NIP-44 using our secret key
679                if let Some(keys) = &self.keys {
680                    if let Some(ciphertext) = self_encrypted_ciphertext {
681                        // Decrypt with NIP-44 (encrypted to self)
682                        let pubkey = keys.public_key();
683                        match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
684                            Ok(key_hex) => {
685                                let key_bytes =
686                                    hex::decode(&key_hex).context("Invalid decrypted key hex")?;
687                                if key_bytes.len() != 32 {
688                                    anyhow::bail!("Decrypted key wrong length");
689                                }
690                                let mut key = [0u8; 32];
691                                key.copy_from_slice(&key_bytes);
692                                Some(key)
693                            }
694                            Err(e) => {
695                                anyhow::bail!(
696                                    "Failed to decrypt private repo: {}\n\
697                                     The repo may be corrupted or published with a different key.",
698                                    e
699                                );
700                            }
701                        }
702                    } else {
703                        anyhow::bail!("selfEncryptedKey tag has invalid format");
704                    }
705                } else {
706                    anyhow::bail!(
707                        "Cannot access this private repo.\n\
708                         Private repos can only be accessed by their author.\n\
709                         You don't have the secret key for this repo's owner."
710                    );
711                }
712            }
713            Some("key") | None => {
714                // Public: use key directly
715                encryption_key
716            }
717            Some(other) => {
718                warn!("Unknown key tag type: {}", other);
719                encryption_key
720            }
721        };
722
723        info!(
724            "Found root hash {} for {} (encrypted: {}, link_visible: {})",
725            &root_hash[..12.min(root_hash.len())],
726            repo_name,
727            unmasked_key.is_some(),
728            self.url_secret.is_some()
729        );
730
731        // Fetch refs from hashtree structure at root_hash
732        let refs = self
733            .fetch_refs_from_hashtree(&root_hash, unmasked_key.as_ref())
734            .await?;
735        Ok((refs, Some(root_hash), unmasked_key))
736    }
737
738    /// Decrypt data if encryption key is provided, then decode as tree node
739    fn decrypt_and_decode(
740        &self,
741        data: &[u8],
742        key: Option<&[u8; 32]>,
743    ) -> Option<hashtree_core::TreeNode> {
744        let decrypted_data: Vec<u8>;
745        let data_to_decode = if let Some(k) = key {
746            match decrypt_chk(data, k) {
747                Ok(d) => {
748                    decrypted_data = d;
749                    &decrypted_data
750                }
751                Err(e) => {
752                    debug!("Decryption failed: {}", e);
753                    return None;
754                }
755            }
756        } else {
757            data
758        };
759
760        match decode_tree_node(data_to_decode) {
761            Ok(node) => Some(node),
762            Err(e) => {
763                debug!("Failed to decode tree node: {}", e);
764                None
765            }
766        }
767    }
768
769    /// Fetch git refs from hashtree structure
770    /// Structure: root -> .git/ -> refs/ -> heads/main -> <sha>
771    async fn fetch_refs_from_hashtree(
772        &self,
773        root_hash: &str,
774        encryption_key: Option<&[u8; 32]>,
775    ) -> Result<HashMap<String, String>> {
776        let mut refs = HashMap::new();
777        debug!(
778            "fetch_refs_from_hashtree: downloading root {}",
779            &root_hash[..12]
780        );
781
782        // Download root directory from Blossom - propagate errors properly
783        let root_data = match self.blossom.download(root_hash).await {
784            Ok(data) => {
785                debug!("Downloaded {} bytes from blossom", data.len());
786                data
787            }
788            Err(e) => {
789                anyhow::bail!(
790                    "Failed to download root hash {}: {}",
791                    &root_hash[..12.min(root_hash.len())],
792                    e
793                );
794            }
795        };
796
797        // Parse root as directory node (decrypt if needed)
798        let root_node = match self.decrypt_and_decode(&root_data, encryption_key) {
799            Some(node) => {
800                debug!("Decoded root node with {} links", node.links.len());
801                node
802            }
803            None => {
804                debug!(
805                    "Failed to decode root node (encryption_key: {})",
806                    encryption_key.is_some()
807                );
808                return Ok(refs);
809            }
810        };
811
812        // Find .git directory
813        debug!(
814            "Root links: {:?}",
815            root_node
816                .links
817                .iter()
818                .map(|l| l.name.as_deref())
819                .collect::<Vec<_>>()
820        );
821        let git_link = root_node
822            .links
823            .iter()
824            .find(|l| l.name.as_deref() == Some(".git"));
825        let (git_hash, git_key) = match git_link {
826            Some(link) => {
827                debug!("Found .git link with key: {}", link.key.is_some());
828                (hex::encode(link.hash), link.key)
829            }
830            None => {
831                debug!("No .git directory in hashtree root");
832                return Ok(refs);
833            }
834        };
835
836        // Download .git directory
837        let git_data = match self.blossom.download(&git_hash).await {
838            Ok(data) => data,
839            Err(e) => {
840                anyhow::bail!(
841                    "Failed to download .git directory ({}): {}",
842                    &git_hash[..12],
843                    e
844                );
845            }
846        };
847
848        let git_node = match self.decrypt_and_decode(&git_data, git_key.as_ref()) {
849            Some(node) => {
850                debug!(
851                    "Decoded .git node with {} links: {:?}",
852                    node.links.len(),
853                    node.links
854                        .iter()
855                        .map(|l| l.name.as_deref())
856                        .collect::<Vec<_>>()
857                );
858                node
859            }
860            None => {
861                debug!("Failed to decode .git node (key: {})", git_key.is_some());
862                return Ok(refs);
863            }
864        };
865
866        // Find refs directory
867        let refs_link = git_node
868            .links
869            .iter()
870            .find(|l| l.name.as_deref() == Some("refs"));
871        let (refs_hash, refs_key) = match refs_link {
872            Some(link) => (hex::encode(link.hash), link.key),
873            None => {
874                debug!("No refs directory in .git");
875                return Ok(refs);
876            }
877        };
878
879        // Download refs directory
880        let refs_data = match self.blossom.try_download(&refs_hash).await {
881            Some(data) => data,
882            None => {
883                debug!("Could not download refs directory");
884                return Ok(refs);
885            }
886        };
887
888        let refs_node = match self.decrypt_and_decode(&refs_data, refs_key.as_ref()) {
889            Some(node) => node,
890            None => {
891                return Ok(refs);
892            }
893        };
894
895        // Look for HEAD in .git directory
896        if let Some(head_link) = git_node
897            .links
898            .iter()
899            .find(|l| l.name.as_deref() == Some("HEAD"))
900        {
901            let head_hash = hex::encode(head_link.hash);
902            if let Some(head_data) = self.blossom.try_download(&head_hash).await {
903                // HEAD is a blob, decrypt if needed
904                let head_content = if let Some(k) = head_link.key.as_ref() {
905                    match decrypt_chk(&head_data, k) {
906                        Ok(d) => String::from_utf8_lossy(&d).trim().to_string(),
907                        Err(_) => String::from_utf8_lossy(&head_data).trim().to_string(),
908                    }
909                } else {
910                    String::from_utf8_lossy(&head_data).trim().to_string()
911                };
912                refs.insert("HEAD".to_string(), head_content);
913            }
914        }
915
916        // Recursively walk refs/ subdirectories (heads, tags, etc.)
917        for subdir_link in &refs_node.links {
918            if subdir_link.link_type != LinkType::Dir {
919                continue;
920            }
921            let subdir_name = match &subdir_link.name {
922                Some(n) => n.clone(),
923                None => continue,
924            };
925            let subdir_hash = hex::encode(subdir_link.hash);
926
927            self.collect_refs_recursive(
928                &subdir_hash,
929                subdir_link.key.as_ref(),
930                &format!("refs/{}", subdir_name),
931                &mut refs,
932            )
933            .await;
934        }
935
936        debug!("Found {} refs from hashtree", refs.len());
937        Ok(refs)
938    }
939
940    /// Recursively collect refs from a directory
941    async fn collect_refs_recursive(
942        &self,
943        dir_hash: &str,
944        dir_key: Option<&[u8; 32]>,
945        prefix: &str,
946        refs: &mut HashMap<String, String>,
947    ) {
948        let dir_data = match self.blossom.try_download(dir_hash).await {
949            Some(data) => data,
950            None => return,
951        };
952
953        let dir_node = match self.decrypt_and_decode(&dir_data, dir_key) {
954            Some(node) => node,
955            None => return,
956        };
957
958        for link in &dir_node.links {
959            let name = match &link.name {
960                Some(n) => n.clone(),
961                None => continue,
962            };
963            let link_hash = hex::encode(link.hash);
964            let ref_path = format!("{}/{}", prefix, name);
965
966            if link.link_type == LinkType::Dir {
967                // Recurse into subdirectory
968                Box::pin(self.collect_refs_recursive(
969                    &link_hash,
970                    link.key.as_ref(),
971                    &ref_path,
972                    refs,
973                ))
974                .await;
975            } else {
976                // This is a ref file - read the SHA
977                if let Some(ref_data) = self.blossom.try_download(&link_hash).await {
978                    // Decrypt if needed
979                    let sha = if let Some(k) = link.key.as_ref() {
980                        match decrypt_chk(&ref_data, k) {
981                            Ok(d) => String::from_utf8_lossy(&d).trim().to_string(),
982                            Err(_) => String::from_utf8_lossy(&ref_data).trim().to_string(),
983                        }
984                    } else {
985                        String::from_utf8_lossy(&ref_data).trim().to_string()
986                    };
987                    if !sha.is_empty() {
988                        debug!("Found ref {} -> {}", ref_path, sha);
989                        refs.insert(ref_path, sha);
990                    }
991                }
992            }
993        }
994    }
995
996    /// Update a ref in local cache (will be published with publish_repo)
997    #[allow(dead_code)]
998    pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
999        info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1000
1001        let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1002        refs.insert(ref_name.to_string(), sha.to_string());
1003
1004        Ok(())
1005    }
1006
1007    /// Delete a ref from local cache
1008    pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1009        info!("Deleting ref {} for {}", ref_name, repo_name);
1010
1011        if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1012            refs.remove(ref_name);
1013        }
1014
1015        Ok(())
1016    }
1017
1018    /// Get cached root hash for a repository
1019    pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1020        self.cached_root_hash.get(repo_name)
1021    }
1022
1023    /// Get cached encryption key for a repository
1024    pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1025        self.cached_encryption_key.get(repo_name)
1026    }
1027
1028    /// Get the Blossom client for direct downloads
1029    pub fn blossom(&self) -> &BlossomClient {
1030        &self.blossom
1031    }
1032
1033    /// Get the configured relay URLs
1034    pub fn relay_urls(&self) -> Vec<String> {
1035        self.relays.clone()
1036    }
1037
1038    /// Get the public key (hex)
1039    #[allow(dead_code)]
1040    pub fn pubkey(&self) -> &str {
1041        &self.pubkey
1042    }
1043
1044    /// Get the public key as npub bech32
1045    pub fn npub(&self) -> String {
1046        PublicKey::from_hex(&self.pubkey)
1047            .ok()
1048            .and_then(|pk| pk.to_bech32().ok())
1049            .unwrap_or_else(|| self.pubkey.clone())
1050    }
1051
1052    /// Publish repository to nostr as kind 30078 event
1053    /// Format:
1054    ///   kind: 30078
1055    ///   tags: [["d", repo_name], ["l", "hashtree"], ["hash", root_hash], ["key"|"encryptedKey", encryption_key]]
1056    ///   content: <merkle-root-hash>
1057    /// Returns: (npub URL, relay result with connected/failed details)
1058    /// If is_private is true, uses "encryptedKey" tag (XOR masked); otherwise uses "key" tag (plaintext CHK)
1059    pub fn publish_repo(
1060        &self,
1061        repo_name: &str,
1062        root_hash: &str,
1063        encryption_key: Option<(&[u8; 32], bool, bool)>,
1064    ) -> Result<(String, RelayResult)> {
1065        let keys = self.keys.as_ref().context(format!(
1066            "Cannot push: no secret key for {}. You can only push to your own repos.",
1067            &self.pubkey[..16]
1068        ))?;
1069
1070        info!(
1071            "Publishing repo {} with root hash {} (encrypted: {})",
1072            repo_name,
1073            root_hash,
1074            encryption_key.is_some()
1075        );
1076
1077        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
1078        let rt = tokio::runtime::Builder::new_multi_thread()
1079            .enable_all()
1080            .build()
1081            .context("Failed to create tokio runtime")?;
1082
1083        let result =
1084            rt.block_on(self.publish_repo_async(keys, repo_name, root_hash, encryption_key));
1085
1086        // Give nostr-sdk background tasks time to clean up gracefully
1087        // This prevents "runtime is shutting down" panics from timer tasks
1088        rt.shutdown_timeout(std::time::Duration::from_millis(500));
1089
1090        result
1091    }
1092
1093    async fn publish_repo_async(
1094        &self,
1095        keys: &Keys,
1096        repo_name: &str,
1097        root_hash: &str,
1098        encryption_key: Option<(&[u8; 32], bool, bool)>,
1099    ) -> Result<(String, RelayResult)> {
1100        // Create nostr-sdk client with our keys
1101        let client = Client::new(keys.clone());
1102
1103        let configured: Vec<String> = self.relays.clone();
1104        let mut connected: Vec<String> = Vec::new();
1105        let mut failed: Vec<String> = Vec::new();
1106
1107        // Add relays
1108        for relay in &self.relays {
1109            if let Err(e) = client.add_relay(relay).await {
1110                warn!("Failed to add relay {}: {}", relay, e);
1111                failed.push(relay.clone());
1112            }
1113        }
1114
1115        // Connect to relays - this starts async connection in background
1116        client.connect().await;
1117
1118        // Wait for at least one relay to connect (same pattern as fetch)
1119        let connect_timeout = Duration::from_secs(3);
1120        let start = std::time::Instant::now();
1121        loop {
1122            let relays = client.relays().await;
1123            let mut any_connected = false;
1124            for (_url, relay) in relays.iter() {
1125                if relay.is_connected().await {
1126                    any_connected = true;
1127                    break;
1128                }
1129            }
1130            if any_connected {
1131                break;
1132            }
1133            if start.elapsed() > connect_timeout {
1134                break;
1135            }
1136            tokio::time::sleep(Duration::from_millis(50)).await;
1137        }
1138
1139        // Build event with tags
1140        let mut tags = vec![
1141            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1142            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1143            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1144        ];
1145
1146        // Add encryption key if present (required for decryption)
1147        // Key modes:
1148        // - selfEncryptedKey: NIP-44 encrypted to self (author-only private)
1149        // - encryptedKey: XOR masked with URL secret (link-visible)
1150        // - key: plaintext CHK (public)
1151        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1152            if is_self_private {
1153                // NIP-44 encrypt to self
1154                let pubkey = keys.public_key();
1155                let key_hex = hex::encode(key);
1156                let encrypted =
1157                    nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1158                        .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1159                tags.push(Tag::custom(
1160                    TagKind::custom("selfEncryptedKey"),
1161                    vec![encrypted],
1162                ));
1163            } else if is_link_visible {
1164                // XOR masked key
1165                tags.push(Tag::custom(
1166                    TagKind::custom("encryptedKey"),
1167                    vec![hex::encode(key)],
1168                ));
1169            } else {
1170                // Public: plaintext CHK
1171                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1172            }
1173        }
1174
1175        // Add directory prefix labels for discoverability
1176        // e.g. "docs/travel/doc1" -> ["l", "docs"], ["l", "docs/travel"]
1177        let parts: Vec<&str> = repo_name.split('/').collect();
1178        for i in 1..parts.len() {
1179            let prefix = parts[..i].join("/");
1180            tags.push(Tag::custom(TagKind::custom("l"), vec![prefix]));
1181        }
1182
1183        // Sign the event
1184        let event = EventBuilder::new(Kind::Custom(KIND_APP_DATA), root_hash, tags)
1185            .to_event(keys)
1186            .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1187
1188        // Send event to connected relays
1189        match client.send_event(event.clone()).await {
1190            Ok(output) => {
1191                // Track which relays confirmed
1192                for url in output.success.iter() {
1193                    let url_str = url.to_string();
1194                    if !connected.contains(&url_str) {
1195                        connected.push(url_str);
1196                    }
1197                }
1198                // Only mark as failed if we got explicit rejection
1199                for (url, err) in output.failed.iter() {
1200                    if err.is_some() {
1201                        let url_str = url.to_string();
1202                        if !failed.contains(&url_str) && !connected.contains(&url_str) {
1203                            failed.push(url_str);
1204                        }
1205                    }
1206                }
1207                info!(
1208                    "Sent event {} to {} relays ({} failed)",
1209                    output.id(),
1210                    output.success.len(),
1211                    output.failed.len()
1212                );
1213            }
1214            Err(e) => {
1215                warn!("Failed to send event: {}", e);
1216                // Mark all as failed
1217                for relay in &self.relays {
1218                    if !failed.contains(relay) {
1219                        failed.push(relay.clone());
1220                    }
1221                }
1222            }
1223        };
1224
1225        // Build the full htree:// URL with npub
1226        let npub_url = keys
1227            .public_key()
1228            .to_bech32()
1229            .map(|npub| format!("htree://{}/{}", npub, repo_name))
1230            .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1231
1232        // Disconnect and give time for cleanup
1233        let _ = client.disconnect().await;
1234        tokio::time::sleep(Duration::from_millis(50)).await;
1235
1236        Ok((
1237            npub_url,
1238            RelayResult {
1239                configured,
1240                connected,
1241                failed,
1242            },
1243        ))
1244    }
1245
1246    /// Fetch open pull requests targeting this repo
1247    pub fn fetch_open_prs(&self, repo_name: &str) -> Result<Vec<OpenPullRequest>> {
1248        let rt = tokio::runtime::Builder::new_multi_thread()
1249            .enable_all()
1250            .build()
1251            .context("Failed to create tokio runtime")?;
1252
1253        let result = rt.block_on(self.fetch_open_prs_async(repo_name));
1254        rt.shutdown_timeout(Duration::from_millis(500));
1255        result
1256    }
1257
1258    async fn fetch_open_prs_async(&self, repo_name: &str) -> Result<Vec<OpenPullRequest>> {
1259        let client = Client::default();
1260
1261        for relay in &self.relays {
1262            if let Err(e) = client.add_relay(relay).await {
1263                warn!("Failed to add relay {}: {}", relay, e);
1264            }
1265        }
1266        client.connect().await;
1267
1268        // Wait for at least one relay (quick timeout)
1269        let start = std::time::Instant::now();
1270        loop {
1271            let relays = client.relays().await;
1272            let mut connected = false;
1273            for relay in relays.values() {
1274                if relay.is_connected().await {
1275                    connected = true;
1276                    break;
1277                }
1278            }
1279            if connected {
1280                break;
1281            }
1282            if start.elapsed() > Duration::from_secs(2) {
1283                let _ = client.disconnect().await;
1284                return Ok(vec![]);
1285            }
1286            tokio::time::sleep(Duration::from_millis(50)).await;
1287        }
1288
1289        // Query for kind 1618 PRs targeting this repo
1290        let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1291        let pr_filter = Filter::new()
1292            .kind(Kind::Custom(KIND_PULL_REQUEST))
1293            .custom_tag(SingleLetterTag::lowercase(Alphabet::A), vec![&repo_address]);
1294
1295        let pr_events = match tokio::time::timeout(
1296            Duration::from_secs(3),
1297            client.get_events_of(vec![pr_filter], EventSource::relays(None)),
1298        )
1299        .await
1300        {
1301            Ok(Ok(events)) => events,
1302            _ => {
1303                let _ = client.disconnect().await;
1304                return Ok(vec![]);
1305            }
1306        };
1307
1308        if pr_events.is_empty() {
1309            let _ = client.disconnect().await;
1310            return Ok(vec![]);
1311        }
1312
1313        // Collect PR event IDs for status query
1314        let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1315
1316        // Query for status events referencing these PRs
1317        let status_filter = Filter::new()
1318            .kinds(vec![
1319                Kind::Custom(KIND_STATUS_OPEN),
1320                Kind::Custom(KIND_STATUS_APPLIED),
1321                Kind::Custom(KIND_STATUS_CLOSED),
1322                Kind::Custom(KIND_STATUS_DRAFT),
1323            ])
1324            .custom_tag(
1325                SingleLetterTag::lowercase(Alphabet::E),
1326                pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1327            );
1328
1329        let status_events = match tokio::time::timeout(
1330            Duration::from_secs(3),
1331            client.get_events_of(vec![status_filter], EventSource::relays(None)),
1332        )
1333        .await
1334        {
1335            Ok(Ok(events)) => events,
1336            Ok(Err(e)) => {
1337                let _ = client.disconnect().await;
1338                return Err(anyhow::anyhow!(
1339                    "Failed to fetch PR status events from relays: {}",
1340                    e
1341                ));
1342            }
1343            Err(_) => {
1344                let _ = client.disconnect().await;
1345                return Err(anyhow::anyhow!(
1346                    "Timed out fetching PR status events from relays"
1347                ));
1348            }
1349        };
1350
1351        let _ = client.disconnect().await;
1352
1353        // Build map: pr_event_id -> latest trusted status kind
1354        let latest_status = latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1355
1356        // Filter to only open PRs (no status or kind 1630)
1357        let mut open_prs = Vec::new();
1358        for event in &pr_events {
1359            let pr_id = event.id.to_hex();
1360            let is_open = match latest_status.get(&pr_id) {
1361                None => true, // No status = open
1362                Some(kind) => *kind == KIND_STATUS_OPEN,
1363            };
1364            if !is_open {
1365                continue;
1366            }
1367
1368            let mut commit_tip = None;
1369            let mut branch = None;
1370            let mut target_branch = None;
1371
1372            for tag in event.tags.iter() {
1373                let slice = tag.as_slice();
1374                if slice.len() >= 2 {
1375                    match slice[0].as_str() {
1376                        "c" => commit_tip = Some(slice[1].to_string()),
1377                        "branch" => branch = Some(slice[1].to_string()),
1378                        "target-branch" => target_branch = Some(slice[1].to_string()),
1379                        _ => {}
1380                    }
1381                }
1382            }
1383
1384            open_prs.push(OpenPullRequest {
1385                event_id: pr_id,
1386                author_pubkey: event.pubkey.to_hex(),
1387                commit_tip,
1388                branch,
1389                target_branch,
1390            });
1391        }
1392
1393        debug!("Found {} open PRs for {}", open_prs.len(), repo_name);
1394        Ok(open_prs)
1395    }
1396
1397    /// Publish a kind 1631 (STATUS_APPLIED) event to mark a PR as merged
1398    pub fn publish_pr_merged_status(
1399        &self,
1400        pr_event_id: &str,
1401        pr_author_pubkey: &str,
1402    ) -> Result<()> {
1403        let keys = self.keys.as_ref().context("Cannot publish status: no secret key")?;
1404
1405        let rt = tokio::runtime::Builder::new_multi_thread()
1406            .enable_all()
1407            .build()
1408            .context("Failed to create tokio runtime")?;
1409
1410        let result = rt.block_on(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey));
1411        rt.shutdown_timeout(Duration::from_millis(500));
1412        result
1413    }
1414
1415    async fn publish_pr_merged_status_async(
1416        &self,
1417        keys: &Keys,
1418        pr_event_id: &str,
1419        pr_author_pubkey: &str,
1420    ) -> Result<()> {
1421        let client = Client::new(keys.clone());
1422
1423        for relay in &self.relays {
1424            if let Err(e) = client.add_relay(relay).await {
1425                warn!("Failed to add relay {}: {}", relay, e);
1426            }
1427        }
1428        client.connect().await;
1429
1430        // Wait for at least one relay
1431        let start = std::time::Instant::now();
1432        loop {
1433            let relays = client.relays().await;
1434            let mut connected = false;
1435            for relay in relays.values() {
1436                if relay.is_connected().await {
1437                    connected = true;
1438                    break;
1439                }
1440            }
1441            if connected {
1442                break;
1443            }
1444            if start.elapsed() > Duration::from_secs(3) {
1445                anyhow::bail!("Failed to connect to any relay for status publish");
1446            }
1447            tokio::time::sleep(Duration::from_millis(50)).await;
1448        }
1449
1450        let tags = vec![
1451            Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
1452            Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
1453        ];
1454
1455        let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "", tags)
1456            .to_event(keys)
1457            .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
1458
1459        let publish_result = match client.send_event(event).await {
1460            Ok(output) => {
1461                if output.success.is_empty() {
1462                    Err(anyhow::anyhow!(
1463                        "PR merged status was not confirmed by any relay"
1464                    ))
1465                } else {
1466                    info!("Published PR merged status to {} relays", output.success.len());
1467                    Ok(())
1468                }
1469            }
1470            Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
1471        };
1472
1473        let _ = client.disconnect().await;
1474        tokio::time::sleep(Duration::from_millis(50)).await;
1475        publish_result
1476    }
1477
1478    /// Upload blob to blossom server
1479    #[allow(dead_code)]
1480    pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
1481        let hash = self
1482            .blossom
1483            .upload(data)
1484            .await
1485            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
1486        Ok(hash)
1487    }
1488
1489    /// Upload blob only if it doesn't exist
1490    #[allow(dead_code)]
1491    pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
1492        self.blossom
1493            .upload_if_missing(data)
1494            .await
1495            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
1496    }
1497
1498    /// Download blob from blossom server
1499    #[allow(dead_code)]
1500    pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
1501        self.blossom
1502            .download(hash)
1503            .await
1504            .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
1505    }
1506
1507    /// Try to download blob, returns None if not found
1508    #[allow(dead_code)]
1509    pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
1510        self.blossom.try_download(hash).await
1511    }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517
1518    const TEST_PUBKEY: &str = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0";
1519
1520    fn test_config() -> Config {
1521        Config::default()
1522    }
1523
1524    #[test]
1525    fn test_new_client() {
1526        let config = test_config();
1527        let client = NostrClient::new(TEST_PUBKEY, None, None, false, &config).unwrap();
1528        assert!(!client.relays.is_empty());
1529        assert!(!client.can_sign());
1530    }
1531
1532    #[test]
1533    fn test_new_client_with_secret() {
1534        let config = test_config();
1535        let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1536        let client =
1537            NostrClient::new(TEST_PUBKEY, Some(secret.to_string()), None, false, &config).unwrap();
1538        assert!(client.can_sign());
1539    }
1540
1541    #[test]
1542    fn test_fetch_refs_empty() {
1543        let config = test_config();
1544        let client = NostrClient::new(TEST_PUBKEY, None, None, false, &config).unwrap();
1545        // This will timeout/return empty without real relays
1546        let refs = client.cached_refs.get("new-repo");
1547        assert!(refs.is_none());
1548    }
1549
1550    #[test]
1551    fn test_update_ref() {
1552        let config = test_config();
1553        let mut client = NostrClient::new(TEST_PUBKEY, None, None, false, &config).unwrap();
1554
1555        client
1556            .update_ref("repo", "refs/heads/main", "abc123")
1557            .unwrap();
1558
1559        let refs = client.cached_refs.get("repo").unwrap();
1560        assert_eq!(refs.get("refs/heads/main"), Some(&"abc123".to_string()));
1561    }
1562
1563    #[test]
1564    fn test_pick_latest_event_prefers_newer_timestamp() {
1565        let keys = Keys::generate();
1566        let older = Timestamp::from_secs(1_700_000_000);
1567        let newer = Timestamp::from_secs(1_700_000_001);
1568
1569        let event_old = EventBuilder::new(Kind::Custom(KIND_APP_DATA), "old", [])
1570            .custom_created_at(older)
1571            .to_event(&keys)
1572            .unwrap();
1573        let event_new = EventBuilder::new(Kind::Custom(KIND_APP_DATA), "new", [])
1574            .custom_created_at(newer)
1575            .to_event(&keys)
1576            .unwrap();
1577
1578        let picked = pick_latest_event([&event_old, &event_new]).unwrap();
1579        assert_eq!(picked.id, event_new.id);
1580    }
1581
1582    #[test]
1583    fn test_pick_latest_event_breaks_ties_with_event_id() {
1584        let keys = Keys::generate();
1585        let created_at = Timestamp::from_secs(1_700_000_000);
1586
1587        let event_a = EventBuilder::new(Kind::Custom(KIND_APP_DATA), "a", [])
1588            .custom_created_at(created_at)
1589            .to_event(&keys)
1590            .unwrap();
1591        let event_b = EventBuilder::new(Kind::Custom(KIND_APP_DATA), "b", [])
1592            .custom_created_at(created_at)
1593            .to_event(&keys)
1594            .unwrap();
1595
1596        let expected_id = if event_a.id > event_b.id {
1597            event_a.id
1598        } else {
1599            event_b.id
1600        };
1601        let picked = pick_latest_event([&event_a, &event_b]).unwrap();
1602        assert_eq!(picked.id, expected_id);
1603    }
1604
1605    #[test]
1606    fn test_stored_key_from_hex() {
1607        let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1608        let key = StoredKey::from_secret_hex(secret, Some("test".to_string())).unwrap();
1609        assert_eq!(key.secret_hex, secret);
1610        assert_eq!(key.petname, Some("test".to_string()));
1611        assert_eq!(key.pubkey_hex.len(), 64);
1612    }
1613
1614    #[test]
1615    fn test_stored_key_from_nsec() {
1616        // This is a test nsec (don't use in production!)
1617        let nsec = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
1618        let key = StoredKey::from_nsec(nsec, None).unwrap();
1619        assert_eq!(key.secret_hex.len(), 64);
1620        assert_eq!(key.pubkey_hex.len(), 64);
1621    }
1622
1623    #[test]
1624    fn test_resolve_identity_hex_pubkey() {
1625        // Hex pubkey without matching secret returns (pubkey, None)
1626        let result = resolve_identity(TEST_PUBKEY);
1627        assert!(result.is_ok());
1628        let (pubkey, secret) = result.unwrap();
1629        assert_eq!(pubkey, TEST_PUBKEY);
1630        // No secret unless we have it in config
1631        assert!(secret.is_none());
1632    }
1633
1634    #[test]
1635    fn test_resolve_identity_npub() {
1636        // Create a pubkey from our test hex
1637        let pk_bytes = hex::decode(TEST_PUBKEY).unwrap();
1638        let pk = PublicKey::from_slice(&pk_bytes).unwrap();
1639        let npub = pk.to_bech32().unwrap();
1640
1641        let result = resolve_identity(&npub);
1642        assert!(result.is_ok(), "Failed: {:?}", result.err());
1643        let (pubkey, _) = result.unwrap();
1644        // Should be valid hex pubkey
1645        assert_eq!(pubkey.len(), 64);
1646        assert_eq!(pubkey, TEST_PUBKEY);
1647    }
1648
1649    #[test]
1650    fn test_resolve_identity_unknown_petname() {
1651        let result = resolve_identity("nonexistent_petname_xyz");
1652        assert!(result.is_err());
1653    }
1654
1655    /// Verify that private repo encryption (NIP-44) produces ciphertext, not plaintext CHK
1656    #[test]
1657    fn test_private_key_is_nip44_encrypted_not_plaintext() {
1658        use nostr_sdk::prelude::{nip44, Keys};
1659
1660        // Create test keys
1661        let keys = Keys::generate();
1662        let pubkey = keys.public_key();
1663
1664        // Test CHK key (32 bytes)
1665        let chk_key: [u8; 32] = [
1666            0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
1667            0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67,
1668            0x89, 0xab, 0xcd, 0xef,
1669        ];
1670        let plaintext_hex = hex::encode(&chk_key);
1671
1672        // Encrypt with NIP-44 (same as publish_repo does for is_self_private=true)
1673        let encrypted = nip44::encrypt(
1674            keys.secret_key(),
1675            &pubkey,
1676            &plaintext_hex,
1677            nip44::Version::V2,
1678        )
1679        .expect("NIP-44 encryption should succeed");
1680
1681        // Critical security check: encrypted value must NOT be plaintext
1682        assert_ne!(
1683            encrypted, plaintext_hex,
1684            "NIP-44 encrypted value must differ from plaintext CHK hex"
1685        );
1686
1687        // Encrypted value should not contain the raw hex (even as substring)
1688        assert!(
1689            !encrypted.contains(&plaintext_hex),
1690            "Encrypted value should not contain plaintext hex"
1691        );
1692
1693        // Verify we can decrypt it back (round-trip)
1694        let decrypted = nip44::decrypt(keys.secret_key(), &pubkey, &encrypted)
1695            .expect("NIP-44 decryption should succeed");
1696
1697        assert_eq!(
1698            decrypted, plaintext_hex,
1699            "Decrypted value should match original plaintext hex"
1700        );
1701    }
1702
1703    /// Verify that different encryption modes produce different tag values
1704    #[test]
1705    fn test_encryption_modes_produce_different_values() {
1706        use nostr_sdk::prelude::{nip44, Keys};
1707
1708        let keys = Keys::generate();
1709        let pubkey = keys.public_key();
1710
1711        // Test CHK key
1712        let chk_key: [u8; 32] = [0xaa; 32];
1713        let plaintext_hex = hex::encode(&chk_key);
1714
1715        // Mode 1: Public (plaintext hex)
1716        let public_value = plaintext_hex.clone();
1717
1718        // Mode 2: Link-visible (XOR masked - in practice, the key passed to publish_repo
1719        // is already XOR'd with url_secret, so we just store hex of that)
1720        // Mode 3: Private (NIP-44 encrypted)
1721        let private_value = nip44::encrypt(
1722            keys.secret_key(),
1723            &pubkey,
1724            &plaintext_hex,
1725            nip44::Version::V2,
1726        )
1727        .expect("NIP-44 encryption should succeed");
1728
1729        // Private value must be different from public
1730        assert_ne!(
1731            private_value, public_value,
1732            "Private (NIP-44) value must differ from public (plaintext) value"
1733        );
1734
1735        // Private value is base64 (NIP-44 output), not hex
1736        assert!(
1737            private_value.len() != 64,
1738            "NIP-44 output should not be 64 chars like hex CHK"
1739        );
1740    }
1741
1742    fn build_test_pr_event(keys: &Keys, created_at_secs: u64) -> Event {
1743        EventBuilder::new(
1744            Kind::Custom(KIND_PULL_REQUEST),
1745            "",
1746            [Tag::custom(
1747                TagKind::custom("subject"),
1748                vec!["test pr".to_string()],
1749            )],
1750        )
1751        .custom_created_at(Timestamp::from_secs(created_at_secs))
1752        .to_event(keys)
1753        .unwrap()
1754    }
1755
1756    fn build_test_status_event(
1757        keys: &Keys,
1758        kind: u16,
1759        pr_event_id: &str,
1760        created_at_secs: u64,
1761    ) -> Event {
1762        EventBuilder::new(
1763            Kind::Custom(kind),
1764            "",
1765            [Tag::custom(
1766                TagKind::custom("e"),
1767                vec![pr_event_id.to_string()],
1768            )],
1769        )
1770        .custom_created_at(Timestamp::from_secs(created_at_secs))
1771        .to_event(keys)
1772        .unwrap()
1773    }
1774
1775    #[test]
1776    fn test_latest_trusted_pr_status_kinds_ignores_untrusted_signers() {
1777        let repo_owner = Keys::generate();
1778        let pr_author = Keys::generate();
1779        let attacker = Keys::generate();
1780
1781        let pr_event = build_test_pr_event(&pr_author, 1_700_100_000);
1782        let spoofed_status = build_test_status_event(
1783            &attacker,
1784            KIND_STATUS_CLOSED,
1785            &pr_event.id.to_hex(),
1786            1_700_100_010,
1787        );
1788
1789        let statuses = latest_trusted_pr_status_kinds(
1790            &[pr_event.clone()],
1791            &[spoofed_status],
1792            &repo_owner.public_key().to_hex(),
1793        );
1794
1795        assert!(
1796            !statuses.contains_key(&pr_event.id.to_hex()),
1797            "untrusted status signer should be ignored"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_latest_trusted_pr_status_kinds_accepts_pr_author() {
1803        let repo_owner = Keys::generate();
1804        let pr_author = Keys::generate();
1805
1806        let pr_event = build_test_pr_event(&pr_author, 1_700_100_000);
1807        let author_status = build_test_status_event(
1808            &pr_author,
1809            KIND_STATUS_CLOSED,
1810            &pr_event.id.to_hex(),
1811            1_700_100_010,
1812        );
1813
1814        let statuses = latest_trusted_pr_status_kinds(
1815            &[pr_event.clone()],
1816            &[author_status],
1817            &repo_owner.public_key().to_hex(),
1818        );
1819
1820        assert_eq!(
1821            statuses.get(&pr_event.id.to_hex()).copied(),
1822            Some(KIND_STATUS_CLOSED)
1823        );
1824    }
1825
1826    #[test]
1827    fn test_latest_trusted_pr_status_kinds_rejects_applied_from_pr_author() {
1828        let repo_owner = Keys::generate();
1829        let pr_author = Keys::generate();
1830
1831        let pr_event = build_test_pr_event(&pr_author, 1_700_100_000);
1832        let author_applied = build_test_status_event(
1833            &pr_author,
1834            KIND_STATUS_APPLIED,
1835            &pr_event.id.to_hex(),
1836            1_700_100_010,
1837        );
1838
1839        let statuses = latest_trusted_pr_status_kinds(
1840            &[pr_event.clone()],
1841            &[author_applied],
1842            &repo_owner.public_key().to_hex(),
1843        );
1844
1845        assert!(
1846            !statuses.contains_key(&pr_event.id.to_hex()),
1847            "PR author must not be able to self-mark applied"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_latest_trusted_pr_status_kinds_accepts_repo_owner() {
1853        let repo_owner = Keys::generate();
1854        let pr_author = Keys::generate();
1855
1856        let pr_event = build_test_pr_event(&pr_author, 1_700_100_000);
1857        let owner_status = build_test_status_event(
1858            &repo_owner,
1859            KIND_STATUS_APPLIED,
1860            &pr_event.id.to_hex(),
1861            1_700_100_010,
1862        );
1863
1864        let statuses = latest_trusted_pr_status_kinds(
1865            &[pr_event.clone()],
1866            &[owner_status],
1867            &repo_owner.public_key().to_hex(),
1868        );
1869
1870        assert_eq!(
1871            statuses.get(&pr_event.id.to_hex()).copied(),
1872            Some(KIND_STATUS_APPLIED)
1873        );
1874    }
1875
1876    #[test]
1877    fn test_latest_trusted_pr_status_kinds_preserves_owner_applied_over_newer_author_status() {
1878        let repo_owner = Keys::generate();
1879        let pr_author = Keys::generate();
1880
1881        let pr_event = build_test_pr_event(&pr_author, 1_700_100_000);
1882        let owner_applied = build_test_status_event(
1883            &repo_owner,
1884            KIND_STATUS_APPLIED,
1885            &pr_event.id.to_hex(),
1886            1_700_100_010,
1887        );
1888        let newer_author_open = build_test_status_event(
1889            &pr_author,
1890            KIND_STATUS_OPEN,
1891            &pr_event.id.to_hex(),
1892            1_700_100_020,
1893        );
1894
1895        let statuses = latest_trusted_pr_status_kinds(
1896            &[pr_event.clone()],
1897            &[owner_applied, newer_author_open],
1898            &repo_owner.public_key().to_hex(),
1899        );
1900
1901        assert_eq!(
1902            statuses.get(&pr_event.id.to_hex()).copied(),
1903            Some(KIND_STATUS_APPLIED),
1904            "owner-applied status should remain authoritative even if author publishes a newer status"
1905        );
1906    }
1907
1908    #[test]
1909    fn test_latest_trusted_pr_status_kinds_ignores_newer_untrusted_status() {
1910        let repo_owner = Keys::generate();
1911        let pr_author = Keys::generate();
1912        let attacker = Keys::generate();
1913
1914        let pr_event = build_test_pr_event(&pr_author, 1_700_100_000);
1915        let trusted_open = build_test_status_event(
1916            &repo_owner,
1917            KIND_STATUS_OPEN,
1918            &pr_event.id.to_hex(),
1919            1_700_100_010,
1920        );
1921        let spoofed_closed = build_test_status_event(
1922            &attacker,
1923            KIND_STATUS_CLOSED,
1924            &pr_event.id.to_hex(),
1925            1_700_100_020,
1926        );
1927
1928        let statuses = latest_trusted_pr_status_kinds(
1929            &[pr_event.clone()],
1930            &[trusted_open, spoofed_closed],
1931            &repo_owner.public_key().to_hex(),
1932        );
1933
1934        assert_eq!(
1935            statuses.get(&pr_event.id.to_hex()).copied(),
1936            Some(KIND_STATUS_OPEN)
1937        );
1938    }
1939}