1use crate::tags::TagsExt;
28use std::path::Path;
29use nostr_sdk::prelude::*;
30use crate::types::{Message, Attachment, ImageMetadata, Reaction};
31use crate::stored_event::{StoredEvent, StoredEventBuilder, event_kind};
32use crate::crypto::{extension_from_mime, sanitize_filename};
33
34#[derive(Debug, Clone)]
36pub struct RumorEvent {
37 pub id: EventId,
38 pub kind: Kind,
39 pub content: String,
40 pub tags: Tags,
41 pub created_at: Timestamp,
42 pub pubkey: PublicKey,
43}
44
45#[derive(Debug, Clone)]
50pub struct RumorContext {
51 pub sender: PublicKey,
53 pub is_mine: bool,
55 pub conversation_id: String,
57 pub conversation_type: ConversationType,
59}
60
61#[derive(Debug, Clone, PartialEq)]
64pub enum ConversationType {
65 DirectMessage,
67 Community,
69}
70
71impl RumorContext {
72 pub fn author_npub(&self, author: &PublicKey) -> Option<String> {
75 match self.conversation_type {
76 ConversationType::Community => author.to_bech32().ok(),
77 ConversationType::DirectMessage => None,
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
88pub enum RumorProcessingResult {
89 TextMessage(Message),
91 FileAttachment(Message),
93 Reaction(Reaction),
95 TypingIndicator {
97 profile_id: String,
98 until: u64,
99 },
100 LeaveRequest {
102 event_id: String,
104 member_pubkey: String,
106 },
107 WebxdcPeerAdvertisement {
109 event_id: String,
110 topic_id: String,
111 node_addr: String,
112 sender_npub: String,
113 created_at: u64,
114 },
115 WebxdcPeerLeft {
117 event_id: String,
118 topic_id: String,
119 sender_npub: String,
120 created_at: u64,
121 },
122 CallSignal {
124 event_id: String,
125 call_id: String,
126 signal: String,
127 node_addr: Option<String>,
128 sender_npub: String,
129 created_at: u64,
130 video: Option<String>,
132 media: Option<String>,
134 },
135 UnknownEvent(StoredEvent),
138 PivxPayment {
140 gift_code: String,
142 amount_piv: f64,
144 address: Option<String>,
146 message_id: String,
148 event: StoredEvent,
150 },
151 WallpaperChanged {
156 sender_npub: String,
158 created_at: u64,
160 url: String,
162 decryption_key: String,
164 decryption_nonce: String,
166 plaintext_hash: Option<String>,
168 mime: Option<String>,
170 blur: Option<u8>,
172 dim: Option<u8>,
174 event_id: String,
176 },
177 Ignored,
179 DeletionRequest {
184 target_event_id: String,
186 },
187 Edit {
189 message_id: String,
191 new_content: String,
193 edited_at: u64,
195 emoji_tags: Vec<crate::types::EmojiTag>,
197 event: StoredEvent,
199 },
200}
201
202pub fn process_rumor(
219 rumor: RumorEvent,
220 context: RumorContext,
221 download_dir: &Path,
222) -> Result<RumorProcessingResult, String> {
223 match rumor.kind {
224 Kind::PrivateDirectMessage => {
226 process_text_message(rumor, context, download_dir)
227 }
228 k if k.as_u16() == 15 => {
230 process_file_attachment(rumor, context, download_dir)
231 }
232 k if k.as_u16() == event_kind::MESSAGE_EDIT => {
234 process_edit_event(rumor, context)
235 }
236 Kind::Reaction => {
238 process_reaction(rumor, context)
239 }
240 Kind::ApplicationSpecificData => {
242 process_app_specific(rumor, context)
243 }
244 Kind::EventDeletion => {
251 process_deletion(rumor, context)
252 }
253 _ => {
255 process_unknown_event(rumor, context)
256 }
257 }
258}
259
260fn process_unknown_event(
265 rumor: RumorEvent,
266 context: RumorContext,
267) -> Result<RumorProcessingResult, String> {
268 let tags: Vec<Vec<String>> = rumor.tags.iter()
270 .map(|tag| {
271 tag.as_slice().iter().map(|s| s.to_string()).collect()
272 })
273 .collect();
274
275 let reference_id = rumor.tags
277 .find_kind("e")
278 .and_then(|tag| tag.content())
279 .map(|s| s.to_string());
280
281 let event = StoredEventBuilder::new()
282 .id(rumor.id.to_hex())
283 .kind(rumor.kind.as_u16())
284 .content(rumor.content)
285 .tags(tags)
286 .reference_id(reference_id)
287 .created_at(rumor.created_at.as_secs())
288 .mine(context.is_mine)
289 .npub(rumor.pubkey.to_bech32().ok())
290 .build();
291
292 Ok(RumorProcessingResult::UnknownEvent(event))
293}
294
295fn process_text_message(
300 rumor: RumorEvent,
301 context: RumorContext,
302 download_dir: &Path,
303) -> Result<RumorProcessingResult, String> {
304 let replied_to = extract_reply_reference(&rumor);
306
307 let ms_timestamp = extract_millisecond_timestamp(&rumor);
309
310 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
311 let addressed_bots = crate::bot_interface::addressed_bots(rumor.tags.iter());
312 let npub = context.author_npub(&rumor.pubkey);
314
315 let expiration = extract_nip40_expiration(&rumor);
317 if already_expired(expiration) {
320 return Ok(RumorProcessingResult::Ignored);
321 }
322 let attachments = crate::community::attachments::attachments_from_tags(rumor.tags.iter(), download_dir);
330 let content = crate::community::attachments::strip_attachment_urls(&rumor.content, &attachments);
333
334 let msg = Message {
335 expiration,
336 id: rumor.id.to_hex(),
337 content,
338 replied_to,
339 replied_to_content: None, replied_to_npub: None,
341 replied_to_has_attachment: None,
342 replied_to_attachment_extension: None,
343 replied_to_emoji_tags: None,
344 preview_metadata: None,
345 at: ms_timestamp,
346 attachments,
347 reactions: Vec::new(),
348 mine: context.is_mine,
349 pending: false,
350 failed: false,
351 npub,
352 wrapper_event_id: None, edited: false,
354 edit_history: None,
355 emoji_tags,
356 addressed_bots,
357 };
358
359 Ok(RumorProcessingResult::TextMessage(msg))
360}
361
362pub fn extract_hash_from_blossom_url(url: &str) -> Option<String> {
366 let path = url.split('/').next_back()?;
367 let hash_part = path.split('.').next()?;
368 if hash_part.len() == 64 && hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
369 Some(hash_part.to_string())
370 } else {
371 None
372 }
373}
374
375fn process_file_attachment(
383 rumor: RumorEvent,
384 context: RumorContext,
385 download_dir: &Path,
386) -> Result<RumorProcessingResult, String> {
387 let decryption_key = rumor.tags
392 .find_kind("decryption-key")
393 .and_then(|tag| tag.content())
394 .unwrap_or_default()
395 .to_string();
396
397 let decryption_nonce = if decryption_key.is_empty() {
398 String::new()
399 } else {
400 rumor.tags
401 .find_kind("decryption-nonce")
402 .and_then(|tag| tag.content())
403 .ok_or("Missing decryption-nonce tag")?
404 .to_string()
405 };
406
407 let original_file_hash = rumor.tags
409 .find_kind("ox")
410 .and_then(|tag| tag.content())
411 .map(|s| s.to_string());
412
413 let content_url = rumor.content.clone();
415
416 const EMPTY_FILE_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
418 if content_url.contains(EMPTY_FILE_HASH) {
419 eprintln!("Skipping attachment with empty file hash in URL: {}", content_url);
420 return Err("Attachment contains empty file hash - skipping".to_string());
421 }
422
423 let img_meta: Option<ImageMetadata> = {
425 let thumbhash_opt = rumor.tags
430 .find_kind("thumbhash")
431 .or_else(|| rumor.tags.find_kind("thumb"))
432 .and_then(|tag| tag.content())
433 .map(|s| s.to_string());
434
435 let dimensions_opt = rumor.tags
436 .find_kind("dim")
437 .and_then(|tag| tag.content())
438 .and_then(|s| {
439 let parts: Vec<&str> = s.split('x').collect();
440 if parts.len() == 2 {
441 let width = parts[0].parse::<u32>().ok()?;
442 let height = parts[1].parse::<u32>().ok()?;
443 Some((width, height))
444 } else {
445 None
446 }
447 });
448
449 match (thumbhash_opt, dimensions_opt) {
450 (Some(thumbhash), Some((width, height))) => {
451 Some(ImageMetadata {
452 thumbhash,
453 width,
454 height,
455 })
456 },
457 _ => None
458 }
459 };
460
461 let mime_type = rumor.tags
463 .find_kind("file-type")
464 .and_then(|tag| tag.content())
465 .ok_or("Missing file-type tag")?;
466 let mime_extension = extension_from_mime(mime_type);
467
468 let file_name = rumor.tags
470 .find_kind("name")
471 .and_then(|tag| tag.content())
472 .map(sanitize_filename)
473 .unwrap_or_default();
474
475 let extension = if !file_name.is_empty() {
478 file_name.rsplit('.').next()
479 .filter(|e| !e.is_empty() && *e != file_name)
480 .map(|e| e.to_lowercase())
481 .unwrap_or(mime_extension)
482 } else {
483 mime_extension
484 };
485
486 let reported_size = rumor.tags
488 .find_kind("size")
489 .and_then(|tag| tag.content())
490 .and_then(|s| s.parse::<u64>().ok())
491 .unwrap_or(0);
492
493 let valid_path_basis =
500 |s: &str| !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit());
501 let original_file_hash = original_file_hash.filter(|h| valid_path_basis(h));
502 if !decryption_key.is_empty() && !valid_path_basis(&decryption_nonce) {
505 return Err("Invalid decryption-nonce tag".to_string());
506 }
507 let file_hash = crate::crypto::attachment_identity_basis(
508 original_file_hash.as_deref(),
509 &decryption_nonce,
510 &content_url,
511 );
512 let hash_file_path = download_dir.join(format!("{}.{}", file_hash, extension));
513 let downloaded = false;
518 let file_path = hash_file_path.to_string_lossy().to_string();
519
520 let replied_to = extract_reply_reference(&rumor);
522
523 let ms_timestamp = extract_millisecond_timestamp(&rumor);
525
526 let webxdc_topic = rumor.tags
530 .find_kind("webxdc-topic")
531 .and_then(|tag| tag.content())
532 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
533 .map(|s| s.to_string());
534
535 let mut fallback_urls: Vec<String> = Vec::new();
539 for tag in rumor.tags.iter() {
540 let parts = tag.as_slice();
541 if parts.first().map(String::as_str) != Some("fallback") {
542 continue;
543 }
544 let Some(u) = parts.get(1) else { continue };
545 if !u.starts_with("https://")
546 || u.contains(char::is_whitespace)
547 || *u == content_url
548 || fallback_urls.contains(u)
549 {
550 continue;
551 }
552 fallback_urls.push(u.clone());
553 if fallback_urls.len() >= 4 {
554 break;
555 }
556 }
557
558 let attachment = Attachment {
560 id: file_hash.clone(),
561 key: decryption_key,
562 nonce: decryption_nonce,
563 extension: extension.to_string(),
564 name: file_name,
565 url: content_url,
566 path: file_path,
567 size: reported_size,
568 img_meta,
569 downloading: false,
570 downloaded,
571 webxdc_topic,
572 group_id: None, original_hash: original_file_hash, fallback_urls,
575 };
576
577 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
578 let npub = context.author_npub(&rumor.pubkey);
580
581 let expiration = extract_nip40_expiration(&rumor);
583 if already_expired(expiration) {
585 return Ok(RumorProcessingResult::Ignored);
586 }
587 let msg = Message {
588 expiration,
589 id: rumor.id.to_hex(),
590 content: String::new(),
591 replied_to,
592 replied_to_content: None, replied_to_npub: None,
594 replied_to_has_attachment: None,
595 replied_to_attachment_extension: None,
596 replied_to_emoji_tags: None,
597 preview_metadata: None,
598 at: ms_timestamp,
599 attachments: vec![attachment],
600 reactions: Vec::new(),
601 mine: context.is_mine,
602 pending: false,
603 failed: false,
604 npub,
605 wrapper_event_id: None, edited: false,
607 edit_history: None,
608 emoji_tags,
609 addressed_bots: crate::bot_interface::addressed_bots(rumor.tags.iter()),
610 };
611
612 Ok(RumorProcessingResult::FileAttachment(msg))
613}
614
615fn unique_event_ref(rumor: &RumorEvent) -> Option<String> {
625 let mut matches = rumor.tags.iter().filter(|t| t.kind() == "e");
626 let first = matches.next()?;
627 if matches.next().is_some() {
628 return None;
629 }
630 first.content().map(|s| s.to_string())
631}
632
633fn extract_nip40_expiration(rumor: &RumorEvent) -> Option<u64> {
636 rumor.tags.iter().find_map(|tag| {
637 let s = tag.as_slice();
638 if s.len() >= 2 && s[0] == "expiration" {
639 s[1].parse::<u64>().ok()
640 } else {
641 None
642 }
643 })
644}
645
646fn already_expired(expiration: Option<u64>) -> bool {
648 match expiration {
649 Some(exp) => web_time::SystemTime::now()
650 .duration_since(web_time::UNIX_EPOCH)
651 .map(|d| exp <= d.as_secs())
652 .unwrap_or(false),
653 None => false,
654 }
655}
656
657fn process_deletion(
658 rumor: RumorEvent,
659 _context: RumorContext,
660) -> Result<RumorProcessingResult, String> {
661 let target_event_id = unique_event_ref(&rumor)
662 .ok_or("Deletion target tag missing or ambiguous")?;
663 Ok(RumorProcessingResult::DeletionRequest { target_event_id })
664}
665
666fn is_renderable_reaction(content: &str) -> bool {
671 if content.is_empty() || content == "+" || content == "-" {
673 return true;
674 }
675 if let Some(inner) = content.strip_prefix(':').and_then(|s| s.strip_suffix(':')) {
678 if !inner.is_empty()
679 && inner.len() <= 48
680 && inner.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '~' | '-' | '+'))
681 {
682 return true;
683 }
684 }
685 content.chars().count() <= 12
689 && !content.chars().any(char::is_whitespace)
690 && !content.contains("://")
691}
692
693fn process_reaction(
697 rumor: RumorEvent,
698 _context: RumorContext,
699) -> Result<RumorProcessingResult, String> {
700 let reference_id = unique_event_ref(&rumor)
701 .ok_or("Reaction reference tag missing or ambiguous")?;
702
703 if !is_renderable_reaction(&rumor.content) {
706 return Ok(RumorProcessingResult::Ignored);
707 }
708
709 let emoji_url = if rumor.content.starts_with(':') && rumor.content.ends_with(':')
712 && rumor.content.len() >= 3
713 {
714 let sc = &rumor.content[1..rumor.content.len() - 1];
715 rumor.tags.iter().find_map(|tag| {
716 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
717 if parts.len() >= 3 && parts[0] == "emoji" && parts[1] == sc {
718 Some(parts[2].to_string())
719 } else {
720 None
721 }
722 })
723 } else {
724 None
725 };
726
727 let reaction = Reaction {
728 id: rumor.id.to_hex(),
729 reference_id,
730 author_id: rumor.pubkey.to_bech32().unwrap_or_else(|_| rumor.pubkey.to_hex()),
731 emoji: rumor.content,
732 emoji_url,
733 };
734
735 Ok(RumorProcessingResult::Reaction(reaction))
736}
737
738fn process_edit_event(
742 rumor: RumorEvent,
743 context: RumorContext,
744) -> Result<RumorProcessingResult, String> {
745 let message_id = unique_event_ref(&rumor)
746 .ok_or("Edit reference tag missing or ambiguous")?;
747
748 let edited_at = extract_millisecond_timestamp(&rumor);
749
750 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
753
754 let tags: Vec<Vec<String>> = rumor.tags.iter()
755 .map(|tag| {
756 tag.as_slice().iter().map(|s| s.to_string()).collect()
757 })
758 .collect();
759
760 let event = StoredEventBuilder::new()
761 .id(rumor.id.to_hex())
762 .kind(event_kind::MESSAGE_EDIT)
763 .content(rumor.content.clone())
764 .tags(tags)
765 .reference_id(Some(message_id.clone()))
766 .created_at(rumor.created_at.as_secs())
767 .mine(context.is_mine)
768 .npub(rumor.pubkey.to_bech32().ok())
769 .build();
770
771 Ok(RumorProcessingResult::Edit {
772 message_id,
773 new_content: rumor.content,
774 edited_at,
775 emoji_tags,
776 event,
777 })
778}
779
780fn process_app_specific(
782 rumor: RumorEvent,
783 context: RumorContext,
784) -> Result<RumorProcessingResult, String> {
785 if is_typing_indicator(&rumor) {
787 let expiry_tag = rumor.tags
788 .find_kind("expiration")
789 .ok_or("Typing indicator missing expiration tag")?;
790
791 let expiry_timestamp: u64 = expiry_tag.content()
792 .ok_or("Expiration tag has no content")?
793 .parse()
794 .map_err(|_| "Invalid expiration timestamp")?;
795
796 let current_timestamp = web_time::SystemTime::now()
797 .duration_since(web_time::UNIX_EPOCH)
798 .map_err(|e| format!("System time error: {}", e))?
799 .as_secs();
800
801 if expiry_timestamp <= current_timestamp || expiry_timestamp > current_timestamp + 30 {
802 return Ok(RumorProcessingResult::Ignored);
803 }
804
805 let profile_id = rumor.pubkey.to_bech32()
806 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
807
808 return Ok(RumorProcessingResult::TypingIndicator {
809 profile_id,
810 until: expiry_timestamp,
811 });
812 }
813
814 if is_leave_request(&rumor) {
816 let member_pubkey = rumor.pubkey.to_bech32()
817 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
818
819 return Ok(RumorProcessingResult::LeaveRequest {
820 event_id: rumor.id.to_hex(),
821 member_pubkey,
822 });
823 }
824
825 if is_pivx_payment(&rumor) {
827 let gift_code = rumor.tags
828 .find_kind("gift-code")
829 .and_then(|tag| tag.content())
830 .ok_or("PIVX payment missing gift-code tag")?
831 .to_string();
832
833 let amount_str = rumor.tags
834 .find_kind("amount")
835 .and_then(|tag| tag.content())
836 .unwrap_or("0");
837 let amount_piv = amount_str.parse::<u64>().unwrap_or(0) as f64 / 100_000_000.0;
838
839 let address = rumor.tags
840 .find_kind("address")
841 .and_then(|tag| tag.content())
842 .map(|s| s.to_string());
843
844 let message_id = rumor.id.to_hex();
845
846 let tags: Vec<Vec<String>> = rumor.tags.iter()
847 .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
848 .collect();
849
850 let event = StoredEventBuilder::new()
851 .id(&message_id)
852 .kind(event_kind::APPLICATION_SPECIFIC)
853 .chat_id(0) .content(&rumor.content)
855 .tags(tags)
856 .created_at(rumor.created_at.as_secs())
857 .mine(context.is_mine)
858 .npub(Some(rumor.pubkey.to_bech32().unwrap_or_default()))
859 .build();
860
861 return Ok(RumorProcessingResult::PivxPayment {
862 gift_code,
863 amount_piv,
864 address,
865 message_id,
866 event,
867 });
868 }
869
870 if is_wallpaper_change(&rumor) {
874 let url = rumor.tags
879 .find_kind("url")
880 .and_then(|tag| tag.content())
881 .unwrap_or_default()
882 .to_string();
883 let decryption_key = rumor.tags
884 .find_kind("decryption-key")
885 .and_then(|tag| tag.content())
886 .unwrap_or_default()
887 .to_string();
888 let decryption_nonce = rumor.tags
889 .find_kind("decryption-nonce")
890 .and_then(|tag| tag.content())
891 .unwrap_or_default()
892 .to_string();
893 let plaintext_hash = rumor.tags
894 .find_kind("x")
895 .and_then(|tag| tag.content())
896 .map(|s| s.to_string());
897 let mime = rumor.tags
898 .find_kind("m")
899 .and_then(|tag| tag.content())
900 .map(|s| s.to_string());
901 let blur = rumor.tags
902 .find_kind("blur")
903 .and_then(|tag| tag.content())
904 .and_then(|s| s.parse::<u32>().ok())
905 .map(|n| n.min(30) as u8);
906 let dim = rumor.tags
907 .find_kind("dim")
908 .and_then(|tag| tag.content())
909 .and_then(|s| s.parse::<u32>().ok())
910 .map(|n| n.min(100) as u8);
911
912 return Ok(RumorProcessingResult::WallpaperChanged {
913 sender_npub: rumor.pubkey.to_bech32().unwrap_or_default(),
914 created_at: rumor.created_at.as_secs(),
915 url,
916 decryption_key,
917 decryption_nonce,
918 plaintext_hash,
919 mime,
920 blur,
921 dim,
922 event_id: rumor.id.to_hex(),
923 });
924 }
925
926 if is_webxdc_peer_advertisement(&rumor) {
928 log_info!("[WEBXDC] Found peer advertisement rumor, is_mine={}, sender={}",
929 context.is_mine,
930 rumor.pubkey.to_bech32().unwrap_or_else(|_| "unknown".to_string()));
931
932 if context.is_mine {
933 log_info!("[WEBXDC] Ignoring our own peer advertisement");
934 return Ok(RumorProcessingResult::Ignored);
935 }
936
937 log_info!("[WEBXDC] Detected peer advertisement in rumor from another device");
938
939 let topic_id = rumor.tags
940 .find_kind("webxdc-topic")
941 .and_then(|tag| tag.content())
942 .ok_or("Peer advertisement missing webxdc-topic tag")?
943 .to_string();
944
945 let node_addr = rumor.tags
946 .find_kind("webxdc-node-addr")
947 .and_then(|tag| tag.content())
948 .ok_or("Peer advertisement missing webxdc-node-addr tag")?
949 .to_string();
950
951 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
952 return Ok(RumorProcessingResult::WebxdcPeerAdvertisement {
953 event_id: rumor.id.to_hex(),
954 topic_id,
955 node_addr,
956 sender_npub,
957 created_at: rumor.created_at.as_secs(),
958 });
959 }
960
961 if is_webxdc_peer_left(&rumor) {
963 if context.is_mine {
964 return Ok(RumorProcessingResult::Ignored);
965 }
966
967 log_info!("[WEBXDC] Detected peer-left signal from another device");
968
969 let topic_id = rumor.tags
970 .find_kind("webxdc-topic")
971 .and_then(|tag| tag.content())
972 .ok_or("Peer-left missing webxdc-topic tag")?
973 .to_string();
974
975 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
976 return Ok(RumorProcessingResult::WebxdcPeerLeft {
977 event_id: rumor.id.to_hex(),
978 topic_id,
979 sender_npub,
980 created_at: rumor.created_at.as_secs(),
981 });
982 }
983
984 if is_call_signal(&rumor) {
985 if context.is_mine {
987 return Ok(RumorProcessingResult::Ignored);
988 }
989 let tag = |kind: &str| rumor.tags.find_kind(kind).and_then(|t| t.content()).map(|s| s.to_string());
990 let call_id = tag("call-id").ok_or("Call signal missing call-id tag")?;
991 let signal = tag("call-signal").ok_or("Call signal missing call-signal tag")?;
992 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
993 return Ok(RumorProcessingResult::CallSignal {
994 event_id: rumor.id.to_hex(),
995 call_id,
996 signal,
997 node_addr: tag("call-node-addr"),
998 sender_npub,
999 created_at: rumor.created_at.as_secs(),
1000 video: tag("call-video"),
1001 media: tag("call-media"),
1002 });
1003 }
1004
1005 Ok(RumorProcessingResult::Ignored)
1007}
1008
1009fn is_call_signal(rumor: &RumorEvent) -> bool {
1011 rumor.tags
1012 .find_kind("d")
1013 .and_then(|tag| tag.content())
1014 .map(|content| content == "vector-call")
1015 .unwrap_or(false)
1016 && rumor.tags.find_kind("call-id").is_some()
1017 && rumor.tags.find_kind("call-signal").is_some()
1018}
1019
1020fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool {
1022 rumor.content == "peer-advertisement"
1023 && rumor.tags.find_kind("webxdc-topic").is_some()
1024 && rumor.tags.find_kind("webxdc-node-addr").is_some()
1025}
1026
1027fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool {
1029 rumor.content == "peer-left"
1030 && rumor.tags.find_kind("webxdc-topic").is_some()
1031}
1032
1033fn is_pivx_payment(rumor: &RumorEvent) -> bool {
1035 rumor.tags
1036 .find_kind("d")
1037 .and_then(|tag| tag.content())
1038 .map(|content| content == "pivx-payment")
1039 .unwrap_or(false)
1040 && rumor.tags.find_kind("gift-code").is_some()
1041}
1042
1043fn extract_millisecond_timestamp(rumor: &RumorEvent) -> u64 {
1052 let ms_tag = rumor.tags
1053 .find_kind("ms")
1054 .and_then(|t| t.content());
1055 resolve_message_timestamp(rumor.created_at.as_secs(), ms_tag)
1056}
1057
1058pub fn resolve_message_timestamp(created_at_secs: u64, ms_tag: Option<&str>) -> u64 {
1069 const FUTURE_GRACE_MS: u64 = 5 * 60 * 1000;
1070 let base = created_at_secs.saturating_mul(1000);
1071 let at = match ms_tag.and_then(|s| s.parse::<u64>().ok()) {
1072 Some(offset) if offset <= 999 => base.saturating_add(offset),
1073 _ => base,
1074 };
1075 let now_ms = web_time::SystemTime::now()
1076 .duration_since(web_time::UNIX_EPOCH)
1077 .map(|d| d.as_millis() as u64)
1078 .unwrap_or(u64::MAX);
1079 if at > now_ms.saturating_add(FUTURE_GRACE_MS) { now_ms } else { at }
1080}
1081
1082fn extract_reply_reference(rumor: &RumorEvent) -> String {
1087 match rumor.tags.find_kind("e") {
1088 Some(tag) if tag.as_slice().get(3).is_some_and(|s| s == "reply") => {
1090 tag.content().unwrap_or("").to_string()
1091 }
1092 _ => String::new(),
1093 }
1094}
1095
1096fn is_typing_indicator(rumor: &RumorEvent) -> bool {
1098 let has_vector_tag = rumor.tags
1099 .find_kind("d")
1100 .and_then(|tag| tag.content())
1101 .map(|content| content == "vector")
1102 .unwrap_or(false);
1103
1104 let is_typing_content = rumor.content == "typing";
1105
1106 has_vector_tag && is_typing_content
1107}
1108
1109fn is_wallpaper_change(rumor: &RumorEvent) -> bool {
1111 rumor.tags
1112 .find_kind("d")
1113 .and_then(|tag| tag.content())
1114 .map(|content| content == "vector-wallpaper")
1115 .unwrap_or(false)
1116}
1117
1118fn is_leave_request(rumor: &RumorEvent) -> bool {
1120 let has_vector_tag = rumor.tags
1121 .find_kind("d")
1122 .and_then(|tag| tag.content())
1123 .map(|content| content == "vector")
1124 .unwrap_or(false);
1125
1126 let is_leave_content = rumor.content == "leave";
1127
1128 has_vector_tag && is_leave_content
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 #[test]
1139 fn ms_resolver_applies_offset_enforces_sub_second_and_clamps_future() {
1140 assert_eq!(resolve_message_timestamp(1500, Some("242")), 1_500_242);
1142 assert_eq!(resolve_message_timestamp(1500, None), 1_500_000);
1144 assert_eq!(resolve_message_timestamp(1500, Some("4242")), 1_500_000);
1146 assert_eq!(resolve_message_timestamp(1500, Some("nope")), 1_500_000);
1147 let now = web_time::SystemTime::now()
1149 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
1150 let clamped = resolve_message_timestamp(253_402_300_800, Some("5"));
1151 assert!(clamped <= (now + 3600) * 1000, "implausible-future ms must clamp to ~now");
1152 }
1153
1154 #[test]
1158 fn ambiguous_target_is_rejected_for_reaction_edit_delete() {
1159 let keys = test_keypair();
1160 let two_e = || tags(vec![
1161 Tag::custom("e", ["aa".repeat(32)]),
1162 Tag::custom("e", ["bb".repeat(32)]),
1163 ]);
1164 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", two_e()), dm_context(&keys), &temp_dir()).is_err());
1165 assert!(process_rumor(make_rumor(&keys, Kind::EventDeletion, "", two_e()), dm_context(&keys), &temp_dir()).is_err());
1166 assert!(process_rumor(make_rumor(&keys, Kind::from(event_kind::MESSAGE_EDIT), "edited", two_e()), dm_context(&keys), &temp_dir()).is_err());
1167 let one_e = tags(vec![Tag::custom("e", ["aa".repeat(32)])]);
1168 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", one_e), dm_context(&keys), &temp_dir()).is_ok());
1169 }
1170
1171 fn test_keypair() -> Keys {
1172 Keys::generate()
1173 }
1174
1175 fn tags(items: Vec<Tag>) -> Tags {
1177 let mut t = Tags::new();
1178 for item in items {
1179 t.push(item);
1180 }
1181 t
1182 }
1183
1184 fn custom_tag(key: &str, values: &[&str]) -> Tag {
1186 let owned: Vec<String> = values.iter().map(|s| s.to_string()).collect();
1187 Tag::custom(key.to_string(), owned)
1188 }
1189
1190 fn make_rumor(keys: &Keys, kind: Kind, content: &str, t: Tags) -> RumorEvent {
1191 RumorEvent {
1192 id: EventId::from_byte_array([0u8; 32]),
1193 kind,
1194 content: content.to_string(),
1195 tags: t,
1196 created_at: Timestamp::from_secs(1700000000),
1197 pubkey: keys.public_key(),
1198 }
1199 }
1200
1201 fn dm_context(keys: &Keys) -> RumorContext {
1202 RumorContext {
1203 sender: keys.public_key(),
1204 is_mine: false,
1205 conversation_id: "npub1test".to_string(),
1206 conversation_type: ConversationType::DirectMessage,
1207 }
1208 }
1209
1210 fn temp_dir() -> std::path::PathBuf {
1211 std::env::temp_dir().join("vector-rumor-test")
1212 }
1213
1214 #[test]
1219 fn test_text_message_dm() {
1220 let keys = test_keypair();
1221 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Hello world!", Tags::new());
1222 let ctx = dm_context(&keys);
1223 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1224
1225 match result {
1226 RumorProcessingResult::TextMessage(msg) => {
1227 assert_eq!(msg.content, "Hello world!");
1228 assert!(!msg.mine);
1229 assert!(msg.npub.is_none());
1230 assert!(msg.attachments.is_empty());
1231 }
1232 _ => panic!("Expected TextMessage"),
1233 }
1234 }
1235
1236 #[test]
1237 fn a_kind_14_imeta_attachment_is_a_file_not_a_link() {
1238 let keys = test_keypair();
1245 let url = "https://blossom.ditto.pub/80f94026dc4fc97f59d131d0f6ce9af1951602f720a9957d6d7189fc1aa4ffcf.m4a";
1246 let imeta = Tag::custom(
1247 "imeta",
1248 vec![
1249 format!("url {url}"),
1250 "m audio/mp4".to_string(),
1251 "waveform 2 100 83 100 2 100 100 100".to_string(),
1252 "duration 58".to_string(),
1253 "encryption-algorithm aes-gcm".to_string(),
1254 "decryption-key 6989b3b663e29897cbb8bbaeda93d7f02d54c5551be9b4fda7aa7cd788a4b7f0".to_string(),
1255 "decryption-nonce d45c254d936ce9e9108e8d7a4e88834c".to_string(),
1256 "ox 5276b12eaa5019742abe86508d0a1ce6ee704603dc384048d0f00fa80b8d7eb8".to_string(),
1257 ],
1258 );
1259 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, url, Tags::from_list(vec![imeta]));
1260 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1261
1262 match result {
1263 RumorProcessingResult::TextMessage(msg) => {
1264 assert_eq!(msg.attachments.len(), 1, "the imeta becomes a real attachment");
1265 let att = &msg.attachments[0];
1266 assert_eq!(att.url, url);
1267 assert_eq!(att.extension, "m4a", "extension resolves from the audio/mp4 mime");
1268 assert_eq!(att.key, "6989b3b663e29897cbb8bbaeda93d7f02d54c5551be9b4fda7aa7cd788a4b7f0");
1269 assert_eq!(att.nonce, "d45c254d936ce9e9108e8d7a4e88834c");
1270 assert_eq!(
1271 att.original_hash.as_deref(),
1272 Some("5276b12eaa5019742abe86508d0a1ce6ee704603dc384048d0f00fa80b8d7eb8"),
1273 "ox is the dedup identity"
1274 );
1275 assert!(!att.downloaded, "arrival never claims the bytes are held");
1276 assert!(msg.content.is_empty(), "the URL renders as the file, not also as a link");
1277 }
1278 _ => panic!("Expected TextMessage carrying an attachment"),
1279 }
1280 }
1281
1282 #[test]
1283 fn a_kind_14_caption_survives_beside_its_attachment() {
1284 let keys = test_keypair();
1286 let url = "https://blossom.example/abc.png";
1287 let imeta = Tag::custom(
1288 "imeta",
1289 vec![format!("url {url}"), "m image/png".to_string()],
1290 );
1291 let rumor = make_rumor(
1292 &keys,
1293 Kind::PrivateDirectMessage,
1294 &format!("look at this\n{url}"),
1295 Tags::from_list(vec![imeta]),
1296 );
1297 match process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap() {
1298 RumorProcessingResult::TextMessage(msg) => {
1299 assert_eq!(msg.content, "look at this");
1300 assert_eq!(msg.attachments.len(), 1);
1301 assert!(msg.attachments[0].key.is_empty());
1303 }
1304 _ => panic!("Expected TextMessage"),
1305 }
1306 }
1307
1308 #[test]
1309 fn test_text_message_mine() {
1310 let keys = test_keypair();
1311 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "My own message", Tags::new());
1312 let ctx = RumorContext {
1313 sender: keys.public_key(),
1314 is_mine: true,
1315 conversation_id: "npub1test".to_string(),
1316 conversation_type: ConversationType::DirectMessage,
1317 };
1318 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1319
1320 match result {
1321 RumorProcessingResult::TextMessage(msg) => {
1322 assert!(msg.mine);
1323 }
1324 _ => panic!("Expected TextMessage"),
1325 }
1326 }
1327
1328 #[test]
1329 fn test_text_message_with_reply() {
1330 let keys = test_keypair();
1331 let t = tags(vec![
1332 Tag::custom("e", ["abc123def456".to_string(), String::new(), "reply".to_string()]),
1333 ]);
1334 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Reply text", t);
1335 let ctx = dm_context(&keys);
1336 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1337
1338 match result {
1339 RumorProcessingResult::TextMessage(msg) => {
1340 assert_eq!(msg.replied_to, "abc123def456");
1341 }
1342 _ => panic!("Expected TextMessage"),
1343 }
1344 }
1345
1346 #[test]
1347 fn test_text_message_with_ms_timestamp() {
1348 let keys = test_keypair();
1349 let t = tags(vec![custom_tag("ms", &["456"])]);
1350 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Precise time", t);
1351 let ctx = dm_context(&keys);
1352 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1353
1354 match result {
1355 RumorProcessingResult::TextMessage(msg) => {
1356 assert_eq!(msg.at, 1700000000 * 1000 + 456);
1357 }
1358 _ => panic!("Expected TextMessage"),
1359 }
1360 }
1361
1362 #[test]
1367 fn test_reaction() {
1368 let keys = test_keypair();
1369 let t = tags(vec![custom_tag("e", &["target_msg_id_hex"])]);
1370 let rumor = make_rumor(&keys, Kind::Reaction, "👍", t);
1371 let ctx = dm_context(&keys);
1372 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1373
1374 match result {
1375 RumorProcessingResult::Reaction(reaction) => {
1376 assert_eq!(reaction.emoji, "👍");
1377 assert_eq!(reaction.reference_id, "target_msg_id_hex");
1378 }
1379 _ => panic!("Expected Reaction"),
1380 }
1381 }
1382
1383 #[test]
1384 fn test_reaction_missing_e_tag() {
1385 let keys = test_keypair();
1386 let rumor = make_rumor(&keys, Kind::Reaction, "👍", Tags::new());
1387 let ctx = dm_context(&keys);
1388 let result = process_rumor(rumor, ctx, &temp_dir());
1389 assert!(result.is_err());
1390 }
1391
1392 #[test]
1393 fn junk_reaction_content_is_dropped_clean_ones_kept() {
1394 for ok in ["👍", "+", "-", "", "👨\u{200d}👩\u{200d}👧\u{200d}👦", ":thugamy:"] {
1396 assert!(is_renderable_reaction(ok), "{ok:?} should be renderable");
1397 }
1398 for junk in [
1400 ":thugamy:https://image.nostr.build/ccc22.png",
1401 "https://example.com/x.png",
1402 "lorem ipsum dolor",
1403 ] {
1404 assert!(!is_renderable_reaction(junk), "{junk:?} should be dropped");
1405 }
1406 assert!(!is_renderable_reaction(&"x".repeat(64)));
1407
1408 let keys = test_keypair();
1410 let t = tags(vec![custom_tag("e", &["target"])]);
1411 let rumor = make_rumor(&keys, Kind::Reaction, ":thugamy:https://image.nostr.build/ccc22.png", t);
1412 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1413 assert!(matches!(result, RumorProcessingResult::Ignored), "junk reaction should be Ignored");
1414 }
1415
1416 #[test]
1421 fn test_edit_event() {
1422 let keys = test_keypair();
1423 let t = tags(vec![custom_tag("e", &["original_msg_id"])]);
1424 let rumor = make_rumor(&keys, Kind::from_u16(16), "Edited content", t);
1425 let ctx = dm_context(&keys);
1426 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1427
1428 match result {
1429 RumorProcessingResult::Edit { message_id, new_content, event, .. } => {
1430 assert_eq!(message_id, "original_msg_id");
1431 assert_eq!(new_content, "Edited content");
1432 assert_eq!(event.kind, event_kind::MESSAGE_EDIT);
1433 }
1434 _ => panic!("Expected Edit"),
1435 }
1436 }
1437
1438 #[test]
1443 fn test_typing_indicator_valid() {
1444 let keys = test_keypair();
1445 let future_ts = web_time::SystemTime::now()
1446 .duration_since(web_time::UNIX_EPOCH).unwrap()
1447 .as_secs() + 10;
1448 let t = tags(vec![
1449 Tag::identifier("vector"),
1450 Tag::expiration(Timestamp::from_secs(future_ts)),
1451 ]);
1452 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1453 let ctx = dm_context(&keys);
1454 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1455
1456 match result {
1457 RumorProcessingResult::TypingIndicator { until, .. } => {
1458 assert_eq!(until, future_ts);
1459 }
1460 _ => panic!("Expected TypingIndicator"),
1461 }
1462 }
1463
1464 #[test]
1465 fn test_typing_indicator_expired() {
1466 let keys = test_keypair();
1467 let t = tags(vec![
1468 Tag::identifier("vector"),
1469 Tag::expiration(Timestamp::from_secs(1000000000)),
1470 ]);
1471 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1472 let ctx = dm_context(&keys);
1473 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1474
1475 assert!(matches!(result, RumorProcessingResult::Ignored));
1476 }
1477
1478 #[test]
1479 fn a_file_without_decryption_tags_is_a_plaintext_reference_not_a_drop() {
1480 let keys = test_keypair();
1484 let ox_hash = "deadbeef".repeat(8);
1485 let t = tags(vec![
1486 custom_tag("ox", &[&ox_hash]),
1487 custom_tag("file-type", &["application/x-webxdc"]),
1488 custom_tag("name", &["game.xdc"]),
1489 ]);
1490 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.xdc", t);
1491 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1492 match result {
1493 RumorProcessingResult::FileAttachment(msg) => {
1494 let att = &msg.attachments[0];
1495 assert!(att.key.is_empty());
1496 assert!(att.nonce.is_empty());
1497 assert_eq!(att.id, ox_hash, "identity must be the plaintext hash claim");
1498 assert!(!att.downloaded, "a reference is a claim, never resident content");
1499 }
1500 other => panic!("expected FileAttachment, got {other:?}"),
1501 }
1502 }
1503
1504 #[test]
1505 fn a_key_without_a_nonce_is_still_malformed() {
1506 let keys = test_keypair();
1509 let t = tags(vec![
1510 custom_tag("decryption-key", &["aabbccdd"]),
1511 custom_tag("ox", &[&"deadbeef".repeat(8)]),
1512 custom_tag("file-type", &["image/jpeg"]),
1513 ]);
1514 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1515 assert!(process_rumor(rumor, dm_context(&keys), &temp_dir()).is_err());
1516 }
1517
1518 #[test]
1523 fn test_expired_text_message_is_dropped_on_receipt() {
1524 let keys = test_keypair();
1525 let t = tags(vec![Tag::expiration(Timestamp::from_secs(1000000000))]);
1526 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "too late", t);
1527 let ctx = dm_context(&keys);
1528 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1529 assert!(matches!(result, RumorProcessingResult::Ignored));
1530 }
1531
1532 #[test]
1533 fn test_expired_file_message_is_dropped_on_receipt() {
1534 let keys = test_keypair();
1535 let ox_hash = "deadbeef".repeat(8);
1536 let t = tags(vec![
1537 custom_tag("decryption-key", &["aabbccdd"]),
1538 custom_tag("decryption-nonce", &["11223344"]),
1539 custom_tag("ox", &[&ox_hash]),
1540 custom_tag("file-type", &["image/jpeg"]),
1541 Tag::expiration(Timestamp::from_secs(1000000000)),
1542 ]);
1543 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1544 let ctx = dm_context(&keys);
1545 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1546 assert!(matches!(result, RumorProcessingResult::Ignored));
1547 }
1548
1549 #[test]
1550 fn test_future_expiration_still_processes() {
1551 let keys = test_keypair();
1552 let future_ts = web_time::SystemTime::now()
1553 .duration_since(web_time::UNIX_EPOCH).unwrap()
1554 .as_secs() + 600;
1555 let t = tags(vec![Tag::expiration(Timestamp::from_secs(future_ts))]);
1556 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "still alive", t);
1557 let ctx = dm_context(&keys);
1558 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1559 match result {
1560 RumorProcessingResult::TextMessage(msg) => {
1561 assert_eq!(msg.expiration, Some(future_ts));
1562 }
1563 _ => panic!("Expected TextMessage"),
1564 }
1565 }
1566
1567 #[test]
1572 fn test_leave_request() {
1573 let keys = test_keypair();
1574 let t = tags(vec![Tag::identifier("vector")]);
1575 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "leave", t);
1576 let ctx = dm_context(&keys);
1577 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1578
1579 match result {
1580 RumorProcessingResult::LeaveRequest { member_pubkey, .. } => {
1581 assert!(!member_pubkey.is_empty());
1582 assert!(member_pubkey.starts_with("npub1"));
1583 }
1584 _ => panic!("Expected LeaveRequest"),
1585 }
1586 }
1587
1588 #[test]
1593 fn test_pivx_payment() {
1594 let keys = test_keypair();
1595 let t = tags(vec![
1596 Tag::identifier("pivx-payment"),
1597 custom_tag("gift-code", &["ABC12"]),
1598 custom_tag("amount", &["100000000"]),
1599 custom_tag("address", &["DTest123"]),
1600 ]);
1601 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "pivx-payment", t);
1602 let ctx = dm_context(&keys);
1603 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1604
1605 match result {
1606 RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, .. } => {
1607 assert_eq!(gift_code, "ABC12");
1608 assert!((amount_piv - 1.0).abs() < f64::EPSILON);
1609 assert_eq!(address, Some("DTest123".to_string()));
1610 }
1611 _ => panic!("Expected PivxPayment"),
1612 }
1613 }
1614
1615 #[test]
1620 fn test_webxdc_peer_advertisement() {
1621 let keys = test_keypair();
1622 let t = tags(vec![
1623 custom_tag("webxdc-topic", &["topic123"]),
1624 custom_tag("webxdc-node-addr", &["addr456"]),
1625 ]);
1626 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1627 let ctx = dm_context(&keys);
1628 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1629
1630 match result {
1631 RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr, .. } => {
1632 assert_eq!(topic_id, "topic123");
1633 assert_eq!(node_addr, "addr456");
1634 }
1635 _ => panic!("Expected WebxdcPeerAdvertisement"),
1636 }
1637 }
1638
1639 #[test]
1640 fn test_webxdc_peer_advertisement_own_ignored() {
1641 let keys = test_keypair();
1642 let t = tags(vec![
1643 custom_tag("webxdc-topic", &["topic123"]),
1644 custom_tag("webxdc-node-addr", &["addr456"]),
1645 ]);
1646 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1647 let ctx = RumorContext {
1648 sender: keys.public_key(),
1649 is_mine: true,
1650 conversation_id: "npub1test".to_string(),
1651 conversation_type: ConversationType::DirectMessage,
1652 };
1653 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1654 assert!(matches!(result, RumorProcessingResult::Ignored));
1655 }
1656
1657 #[test]
1658 fn test_webxdc_peer_left() {
1659 let keys = test_keypair();
1660 let t = tags(vec![custom_tag("webxdc-topic", &["topic123"])]);
1661 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-left", t);
1662 let ctx = dm_context(&keys);
1663 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1664
1665 match result {
1666 RumorProcessingResult::WebxdcPeerLeft { topic_id, .. } => {
1667 assert_eq!(topic_id, "topic123");
1668 }
1669 _ => panic!("Expected WebxdcPeerLeft"),
1670 }
1671 }
1672
1673 #[test]
1678 fn test_unknown_kind() {
1679 let keys = test_keypair();
1680 let rumor = make_rumor(&keys, Kind::from_u16(65535), "Mystery event", Tags::new());
1681 let ctx = dm_context(&keys);
1682 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1683
1684 match result {
1685 RumorProcessingResult::UnknownEvent(event) => {
1686 assert_eq!(event.kind, 65535);
1687 assert_eq!(event.content, "Mystery event");
1688 }
1689 _ => panic!("Expected UnknownEvent"),
1690 }
1691 }
1692
1693 #[test]
1698 fn test_file_attachment() {
1699 let keys = test_keypair();
1700 let ox_hash = "deadbeef".repeat(8); let t = tags(vec![
1702 custom_tag("decryption-key", &["aabbccdd"]),
1703 custom_tag("decryption-nonce", &["11223344"]),
1704 custom_tag("ox", &[&ox_hash]),
1705 custom_tag("file-type", &["image/jpeg"]),
1706 custom_tag("name", &["photo.jpg"]),
1707 custom_tag("size", &["12345"]),
1708 ]);
1709 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1710 let ctx = dm_context(&keys);
1711 let dir = temp_dir();
1712 let result = process_rumor(rumor, ctx, &dir).unwrap();
1713
1714 match result {
1715 RumorProcessingResult::FileAttachment(msg) => {
1716 assert_eq!(msg.attachments.len(), 1);
1717 let att = &msg.attachments[0];
1718 assert_eq!(att.key, "aabbccdd");
1719 assert_eq!(att.nonce, "11223344");
1720 assert_eq!(att.extension, "jpg");
1721 assert_eq!(att.name, "photo.jpg");
1722 assert_eq!(att.size, 12345);
1723 assert!(!att.downloaded);
1724 }
1725 _ => panic!("Expected FileAttachment"),
1726 }
1727 }
1728
1729 #[test]
1730 fn test_file_attachment_fallback_mirrors() {
1731 let keys = test_keypair();
1732 let ox_hash = "deadbeef".repeat(8);
1733 let t = tags(vec![
1734 custom_tag("decryption-key", &["aabbccdd"]),
1735 custom_tag("decryption-nonce", &["11223344"]),
1736 custom_tag("ox", &[&ox_hash]),
1737 custom_tag("file-type", &["image/jpeg"]),
1738 custom_tag("fallback", &["https://mirror-one.example/deadbeef.jpg"]),
1739 custom_tag("fallback", &["https://blossom.example/deadbeef.jpg"]),
1742 custom_tag("fallback", &["http://insecure.example/deadbeef.jpg"]),
1743 custom_tag("fallback", &["https://sneaky.example/a b.jpg"]),
1744 custom_tag("fallback", &["https://mirror-one.example/deadbeef.jpg"]),
1745 custom_tag("fallback", &["https://mirror-two.example/deadbeef.jpg"]),
1746 ]);
1747 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1748 let ctx = dm_context(&keys);
1749 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1750
1751 match result {
1752 RumorProcessingResult::FileAttachment(msg) => {
1753 let att = &msg.attachments[0];
1754 assert_eq!(att.fallback_urls, vec![
1755 "https://mirror-one.example/deadbeef.jpg".to_string(),
1756 "https://mirror-two.example/deadbeef.jpg".to_string(),
1757 ]);
1758 }
1759 _ => panic!("Expected FileAttachment"),
1760 }
1761 }
1762
1763 #[test]
1764 fn test_file_attachment_hostile_path_basis_rejected() {
1765 let keys = test_keypair();
1766 let dir = temp_dir();
1767 let ctx = || dm_context(&keys);
1768
1769 let t = tags(vec![
1773 custom_tag("decryption-key", &["aabbccdd"]),
1774 custom_tag("decryption-nonce", &["11223344"]),
1775 custom_tag("ox", &["../../../etc/passwd"]),
1776 custom_tag("file-type", &["image/jpeg"]),
1777 custom_tag("name", &["x.jpg"]),
1778 ]);
1779 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/x.jpg", t);
1780 let expected_id = crate::crypto::attachment_identity_basis(None, "11223344", "https://blossom.example/x.jpg");
1781 match process_rumor(rumor, ctx(), &dir).unwrap() {
1782 RumorProcessingResult::FileAttachment(msg) => {
1783 let att = &msg.attachments[0];
1784 assert!(!att.path.contains(".."), "traversal basis must not reach the path: {}", att.path);
1785 assert_eq!(att.id, expected_id, "id falls back to the nonce+url digest");
1786 }
1787 _ => panic!("Expected FileAttachment"),
1788 }
1789
1790 let t = tags(vec![
1792 custom_tag("decryption-key", &["aabbccdd"]),
1793 custom_tag("decryption-nonce", &["../../../etc/cron.d/evil"]),
1794 custom_tag("file-type", &["image/jpeg"]),
1795 ]);
1796 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/y.jpg", t);
1797 assert!(process_rumor(rumor, ctx(), &dir).is_err());
1798 }
1799
1800 #[test]
1801 fn test_file_attachment_empty_hash_rejected() {
1802 let keys = test_keypair();
1803 let t = tags(vec![
1804 custom_tag("decryption-key", &["aabbccdd"]),
1805 custom_tag("decryption-nonce", &["11223344"]),
1806 custom_tag("file-type", &["image/jpeg"]),
1807 ]);
1808 let empty_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1809 let rumor = make_rumor(&keys, Kind::from_u16(15), &format!("https://blossom.example/{}", empty_hash), t);
1810 let ctx = dm_context(&keys);
1811 let result = process_rumor(rumor, ctx, &temp_dir());
1812 assert!(result.is_err());
1813 }
1814
1815 #[test]
1816 fn test_file_attachment_with_image_meta() {
1817 let keys = test_keypair();
1818 let ox_hash = "a".repeat(64);
1819 let t = tags(vec![
1820 custom_tag("decryption-key", &["aabbccdd"]),
1821 custom_tag("decryption-nonce", &["11223344"]),
1822 custom_tag("ox", &[&ox_hash]),
1823 custom_tag("file-type", &["image/png"]),
1824 custom_tag("thumbhash", &["base64data"]),
1825 custom_tag("dim", &["1920x1080"]),
1826 custom_tag("size", &["5000"]),
1827 ]);
1828 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/aaa.png", t);
1829 let ctx = dm_context(&keys);
1830 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1831
1832 match result {
1833 RumorProcessingResult::FileAttachment(msg) => {
1834 let att = &msg.attachments[0];
1835 let meta = att.img_meta.as_ref().unwrap();
1836 assert_eq!(meta.width, 1920);
1837 assert_eq!(meta.height, 1080);
1838 assert_eq!(meta.thumbhash, "base64data");
1839 }
1840 _ => panic!("Expected FileAttachment"),
1841 }
1842 }
1843
1844 #[test]
1850 fn test_file_attachment_thumb_tag_is_read() {
1851 let keys = test_keypair();
1852 let ox_hash = "b".repeat(64);
1853 let t = tags(vec![
1854 custom_tag("decryption-key", &["aabbccdd"]),
1855 custom_tag("decryption-nonce", &["11223344"]),
1856 custom_tag("ox", &[&ox_hash]),
1857 custom_tag("file-type", &["image/png"]),
1858 custom_tag("thumb", &["realwiretag"]),
1859 custom_tag("dim", &["800x600"]),
1860 custom_tag("size", &["5000"]),
1861 ]);
1862 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/bbb.png", t);
1863 let ctx = dm_context(&keys);
1864 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1865
1866 match result {
1867 RumorProcessingResult::FileAttachment(msg) => {
1868 let meta = msg.attachments[0].img_meta.as_ref()
1869 .expect("img_meta must be populated from the `thumb` tag");
1870 assert_eq!(meta.thumbhash, "realwiretag");
1871 assert_eq!(meta.width, 800);
1872 assert_eq!(meta.height, 600);
1873 }
1874 _ => panic!("Expected FileAttachment"),
1875 }
1876 }
1877
1878 #[test]
1883 fn test_extract_hash_from_blossom_url() {
1884 let hash = "a".repeat(64);
1885 let url = format!("https://blossom.example/{}.jpg", hash);
1886 assert_eq!(extract_hash_from_blossom_url(&url), Some(hash));
1887
1888 assert_eq!(extract_hash_from_blossom_url("https://example.com/short"), None);
1889 assert_eq!(extract_hash_from_blossom_url("https://example.com/not-hex-at-all-but-exactly-sixty-four-characters-long-string-here!"), None);
1890 }
1891
1892 #[test]
1893 fn test_unknown_app_specific_ignored() {
1894 let keys = test_keypair();
1895 let t = tags(vec![Tag::identifier("some-other-app")]);
1896 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "unknown-content", t);
1897 let ctx = dm_context(&keys);
1898 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1899 assert!(matches!(result, RumorProcessingResult::Ignored));
1900 }
1901}