1use 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
45pub const KIND_APP_DATA: u16 = 30078;
47
48pub 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
56pub const LABEL_HASHTREE: &str = "hashtree";
58
59#[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#[derive(Debug, Clone)]
71pub struct StoredKey {
72 pub secret_hex: String,
74 pub pubkey_hex: String,
76 pub petname: Option<String>,
78}
79
80impl StoredKey {
81 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 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
107pub fn load_keys() -> Vec<StoredKey> {
109 let mut keys = Vec::new();
110
111 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
136pub fn resolve_identity(identifier: &str) -> Result<(String, Option<String>)> {
143 let keys = load_keys();
144
145 if identifier == "self" {
147 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 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 if let Some(key) = keys.first() {
160 return Ok((key.pubkey_hex.clone(), Some(key.secret_hex.clone())));
161 }
162 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 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 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 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 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 anyhow::bail!(
202 "Unknown identity '{}'. Add it to ~/.hashtree/keys or use a pubkey/npub.",
203 identifier
204 )
205}
206
207fn generate_and_save_key(petname: &str) -> Result<StoredKey> {
209 use std::fs::{self, OpenOptions};
210 use std::io::Write;
211
212 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 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 let mut file = OpenOptions::new()
225 .create(true)
226 .append(true)
227 .open(&keys_path)?;
228
229 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 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 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 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#[derive(Debug, Clone)]
317pub struct RelayResult {
318 #[allow(dead_code)]
320 pub configured: Vec<String>,
321 pub connected: Vec<String>,
323 pub failed: Vec<String>,
325}
326
327#[derive(Debug, Clone)]
329pub struct BlossomResult {
330 #[allow(dead_code)]
332 pub configured: Vec<String>,
333 pub succeeded: Vec<String>,
335 pub failed: Vec<String>,
337}
338
339pub struct NostrClient {
341 pubkey: String,
342 keys: Option<Keys>,
344 relays: Vec<String>,
345 blossom: BlossomClient,
346 cached_refs: HashMap<String, HashMap<String, String>>,
348 cached_root_hash: HashMap<String, String>,
350 cached_encryption_key: HashMap<String, [u8; 32]>,
352 url_secret: Option<[u8; 32]>,
355 is_private: bool,
357}
358
359impl NostrClient {
360 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 let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
370
371 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 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 #[allow(dead_code)]
412 pub fn can_sign(&self) -> bool {
413 self.keys.is_some()
414 }
415
416 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 #[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 #[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 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 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 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 let client = Client::default();
487
488 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 client.connect().await;
497
498 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 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 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 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 let _ = client.disconnect().await;
576
577 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 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 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 return Some((None, Some(tag_name.to_string()), Some(tag_value)));
635 } else if tag_name == "key" || tag_name == "encryptedKey" {
636 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 let unmasked_key = match key_tag_name.as_deref() {
652 Some("encryptedKey") => {
653 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 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 if let Some(keys) = &self.keys {
680 if let Some(ciphertext) = self_encrypted_ciphertext {
681 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(ref_data) = self.blossom.try_download(&link_hash).await {
978 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 #[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 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 pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1020 self.cached_root_hash.get(repo_name)
1021 }
1022
1023 pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1025 self.cached_encryption_key.get(repo_name)
1026 }
1027
1028 pub fn blossom(&self) -> &BlossomClient {
1030 &self.blossom
1031 }
1032
1033 pub fn relay_urls(&self) -> Vec<String> {
1035 self.relays.clone()
1036 }
1037
1038 #[allow(dead_code)]
1040 pub fn pubkey(&self) -> &str {
1041 &self.pubkey
1042 }
1043
1044 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 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 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 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 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 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 client.connect().await;
1117
1118 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 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 if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1152 if is_self_private {
1153 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 tags.push(Tag::custom(
1166 TagKind::custom("encryptedKey"),
1167 vec![hex::encode(key)],
1168 ));
1169 } else {
1170 tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1172 }
1173 }
1174
1175 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 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 match client.send_event(event.clone()).await {
1190 Ok(output) => {
1191 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 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 for relay in &self.relays {
1218 if !failed.contains(relay) {
1219 failed.push(relay.clone());
1220 }
1221 }
1222 }
1223 };
1224
1225 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 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 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 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 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 let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1315
1316 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 let latest_status = latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1355
1356 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, 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 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 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 #[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 #[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 #[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 #[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 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 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 let result = resolve_identity(TEST_PUBKEY);
1627 assert!(result.is_ok());
1628 let (pubkey, secret) = result.unwrap();
1629 assert_eq!(pubkey, TEST_PUBKEY);
1630 assert!(secret.is_none());
1632 }
1633
1634 #[test]
1635 fn test_resolve_identity_npub() {
1636 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 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 #[test]
1657 fn test_private_key_is_nip44_encrypted_not_plaintext() {
1658 use nostr_sdk::prelude::{nip44, Keys};
1659
1660 let keys = Keys::generate();
1662 let pubkey = keys.public_key();
1663
1664 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 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 assert_ne!(
1683 encrypted, plaintext_hex,
1684 "NIP-44 encrypted value must differ from plaintext CHK hex"
1685 );
1686
1687 assert!(
1689 !encrypted.contains(&plaintext_hex),
1690 "Encrypted value should not contain plaintext hex"
1691 );
1692
1693 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 #[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 let chk_key: [u8; 32] = [0xaa; 32];
1713 let plaintext_hex = hex::encode(&chk_key);
1714
1715 let public_value = plaintext_hex.clone();
1717
1718 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 assert_ne!(
1731 private_value, public_value,
1732 "Private (NIP-44) value must differ from public (plaintext) value"
1733 );
1734
1735 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}