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 UnknownEvent(StoredEvent),
125 PivxPayment {
127 gift_code: String,
129 amount_piv: f64,
131 address: Option<String>,
133 message_id: String,
135 event: StoredEvent,
137 },
138 WallpaperChanged {
143 sender_npub: String,
145 created_at: u64,
147 url: String,
149 decryption_key: String,
151 decryption_nonce: String,
153 plaintext_hash: Option<String>,
155 mime: Option<String>,
157 blur: Option<u8>,
159 dim: Option<u8>,
161 event_id: String,
163 },
164 Ignored,
166 DeletionRequest {
171 target_event_id: String,
173 },
174 Edit {
176 message_id: String,
178 new_content: String,
180 edited_at: u64,
182 emoji_tags: Vec<crate::types::EmojiTag>,
184 event: StoredEvent,
186 },
187}
188
189pub fn process_rumor(
206 rumor: RumorEvent,
207 context: RumorContext,
208 download_dir: &Path,
209) -> Result<RumorProcessingResult, String> {
210 match rumor.kind {
211 Kind::PrivateDirectMessage => {
213 process_text_message(rumor, context, download_dir)
214 }
215 k if k.as_u16() == 15 => {
217 process_file_attachment(rumor, context, download_dir)
218 }
219 k if k.as_u16() == event_kind::MESSAGE_EDIT => {
221 process_edit_event(rumor, context)
222 }
223 Kind::Reaction => {
225 process_reaction(rumor, context)
226 }
227 Kind::ApplicationSpecificData => {
229 process_app_specific(rumor, context)
230 }
231 Kind::EventDeletion => {
238 process_deletion(rumor, context)
239 }
240 _ => {
242 process_unknown_event(rumor, context)
243 }
244 }
245}
246
247fn process_unknown_event(
252 rumor: RumorEvent,
253 context: RumorContext,
254) -> Result<RumorProcessingResult, String> {
255 let tags: Vec<Vec<String>> = rumor.tags.iter()
257 .map(|tag| {
258 tag.as_slice().iter().map(|s| s.to_string()).collect()
259 })
260 .collect();
261
262 let reference_id = rumor.tags
264 .find_kind("e")
265 .and_then(|tag| tag.content())
266 .map(|s| s.to_string());
267
268 let event = StoredEventBuilder::new()
269 .id(rumor.id.to_hex())
270 .kind(rumor.kind.as_u16())
271 .content(rumor.content)
272 .tags(tags)
273 .reference_id(reference_id)
274 .created_at(rumor.created_at.as_secs())
275 .mine(context.is_mine)
276 .npub(rumor.pubkey.to_bech32().ok())
277 .build();
278
279 Ok(RumorProcessingResult::UnknownEvent(event))
280}
281
282fn process_text_message(
287 rumor: RumorEvent,
288 context: RumorContext,
289 download_dir: &Path,
290) -> Result<RumorProcessingResult, String> {
291 let replied_to = extract_reply_reference(&rumor);
293
294 let ms_timestamp = extract_millisecond_timestamp(&rumor);
296
297 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
298 let addressed_bots = crate::bot_interface::addressed_bots(rumor.tags.iter());
299 let npub = context.author_npub(&rumor.pubkey);
301
302 let expiration = extract_nip40_expiration(&rumor);
304 if already_expired(expiration) {
307 return Ok(RumorProcessingResult::Ignored);
308 }
309 let attachments = crate::community::attachments::attachments_from_tags(rumor.tags.iter(), download_dir);
317 let content = crate::community::attachments::strip_attachment_urls(&rumor.content, &attachments);
320
321 let msg = Message {
322 expiration,
323 id: rumor.id.to_hex(),
324 content,
325 replied_to,
326 replied_to_content: None, replied_to_npub: None,
328 replied_to_has_attachment: None,
329 replied_to_attachment_extension: None,
330 replied_to_emoji_tags: None,
331 preview_metadata: None,
332 at: ms_timestamp,
333 attachments,
334 reactions: Vec::new(),
335 mine: context.is_mine,
336 pending: false,
337 failed: false,
338 npub,
339 wrapper_event_id: None, edited: false,
341 edit_history: None,
342 emoji_tags,
343 addressed_bots,
344 };
345
346 Ok(RumorProcessingResult::TextMessage(msg))
347}
348
349pub fn extract_hash_from_blossom_url(url: &str) -> Option<String> {
353 let path = url.split('/').last()?;
354 let hash_part = path.split('.').next()?;
355 if hash_part.len() == 64 && hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
356 Some(hash_part.to_string())
357 } else {
358 None
359 }
360}
361
362fn process_file_attachment(
370 rumor: RumorEvent,
371 context: RumorContext,
372 download_dir: &Path,
373) -> Result<RumorProcessingResult, String> {
374 let decryption_key = rumor.tags
376 .find_kind("decryption-key")
377 .and_then(|tag| tag.content())
378 .ok_or("Missing decryption-key tag")?
379 .to_string();
380
381 let decryption_nonce = rumor.tags
382 .find_kind("decryption-nonce")
383 .and_then(|tag| tag.content())
384 .ok_or("Missing decryption-nonce tag")?
385 .to_string();
386
387 let original_file_hash = rumor.tags
389 .find_kind("ox")
390 .and_then(|tag| tag.content())
391 .map(|s| s.to_string());
392
393 let content_url = rumor.content.clone();
395
396 const EMPTY_FILE_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
398 if content_url.contains(EMPTY_FILE_HASH) {
399 eprintln!("Skipping attachment with empty file hash in URL: {}", content_url);
400 return Err("Attachment contains empty file hash - skipping".to_string());
401 }
402
403 let img_meta: Option<ImageMetadata> = {
405 let thumbhash_opt = rumor.tags
410 .find_kind("thumbhash")
411 .or_else(|| rumor.tags.find_kind("thumb"))
412 .and_then(|tag| tag.content())
413 .map(|s| s.to_string());
414
415 let dimensions_opt = rumor.tags
416 .find_kind("dim")
417 .and_then(|tag| tag.content())
418 .and_then(|s| {
419 let parts: Vec<&str> = s.split('x').collect();
420 if parts.len() == 2 {
421 let width = parts[0].parse::<u32>().ok()?;
422 let height = parts[1].parse::<u32>().ok()?;
423 Some((width, height))
424 } else {
425 None
426 }
427 });
428
429 match (thumbhash_opt, dimensions_opt) {
430 (Some(thumbhash), Some((width, height))) => {
431 Some(ImageMetadata {
432 thumbhash,
433 width,
434 height,
435 })
436 },
437 _ => None
438 }
439 };
440
441 let mime_type = rumor.tags
443 .find_kind("file-type")
444 .and_then(|tag| tag.content())
445 .ok_or("Missing file-type tag")?;
446 let mime_extension = extension_from_mime(mime_type);
447
448 let file_name = rumor.tags
450 .find_kind("name")
451 .and_then(|tag| tag.content())
452 .map(|s| sanitize_filename(s))
453 .unwrap_or_default();
454
455 let extension = if !file_name.is_empty() {
458 file_name.rsplit('.').next()
459 .filter(|e| !e.is_empty() && *e != file_name)
460 .map(|e| e.to_lowercase())
461 .unwrap_or(mime_extension)
462 } else {
463 mime_extension
464 };
465
466 let reported_size = rumor.tags
468 .find_kind("size")
469 .and_then(|tag| tag.content())
470 .and_then(|s| s.parse::<u64>().ok())
471 .unwrap_or(0);
472
473 let valid_path_basis =
480 |s: &str| !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit());
481 let original_file_hash = original_file_hash.filter(|h| valid_path_basis(h));
482 if !valid_path_basis(&decryption_nonce) {
483 return Err("Invalid decryption-nonce tag".to_string());
484 }
485 let file_hash = crate::crypto::attachment_identity_basis(
486 original_file_hash.as_deref(),
487 &decryption_nonce,
488 &content_url,
489 );
490 let hash_file_path = download_dir.join(format!("{}.{}", file_hash, extension));
491 let downloaded = false;
496 let file_path = hash_file_path.to_string_lossy().to_string();
497
498 let replied_to = extract_reply_reference(&rumor);
500
501 let ms_timestamp = extract_millisecond_timestamp(&rumor);
503
504 let webxdc_topic = rumor.tags
508 .find_kind("webxdc-topic")
509 .and_then(|tag| tag.content())
510 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
511 .map(|s| s.to_string());
512
513 let mut fallback_urls: Vec<String> = Vec::new();
517 for tag in rumor.tags.iter() {
518 let parts = tag.as_slice();
519 if parts.first().map(String::as_str) != Some("fallback") {
520 continue;
521 }
522 let Some(u) = parts.get(1) else { continue };
523 if !u.starts_with("https://")
524 || u.contains(char::is_whitespace)
525 || *u == content_url
526 || fallback_urls.contains(u)
527 {
528 continue;
529 }
530 fallback_urls.push(u.clone());
531 if fallback_urls.len() >= 4 {
532 break;
533 }
534 }
535
536 let attachment = Attachment {
538 id: file_hash.clone(),
539 key: decryption_key,
540 nonce: decryption_nonce,
541 extension: extension.to_string(),
542 name: file_name,
543 url: content_url,
544 path: file_path,
545 size: reported_size,
546 img_meta,
547 downloading: false,
548 downloaded,
549 webxdc_topic,
550 group_id: None, original_hash: original_file_hash, fallback_urls,
553 };
554
555 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
556 let npub = context.author_npub(&rumor.pubkey);
558
559 let expiration = extract_nip40_expiration(&rumor);
561 if already_expired(expiration) {
563 return Ok(RumorProcessingResult::Ignored);
564 }
565 let msg = Message {
566 expiration,
567 id: rumor.id.to_hex(),
568 content: String::new(),
569 replied_to,
570 replied_to_content: None, replied_to_npub: None,
572 replied_to_has_attachment: None,
573 replied_to_attachment_extension: None,
574 replied_to_emoji_tags: None,
575 preview_metadata: None,
576 at: ms_timestamp,
577 attachments: vec![attachment],
578 reactions: Vec::new(),
579 mine: context.is_mine,
580 pending: false,
581 failed: false,
582 npub,
583 wrapper_event_id: None, edited: false,
585 edit_history: None,
586 emoji_tags,
587 addressed_bots: crate::bot_interface::addressed_bots(rumor.tags.iter()),
588 };
589
590 Ok(RumorProcessingResult::FileAttachment(msg))
591}
592
593fn unique_event_ref(rumor: &RumorEvent) -> Option<String> {
603 let mut matches = rumor.tags.iter().filter(|t| t.kind() == "e");
604 let first = matches.next()?;
605 if matches.next().is_some() {
606 return None;
607 }
608 first.content().map(|s| s.to_string())
609}
610
611fn extract_nip40_expiration(rumor: &RumorEvent) -> Option<u64> {
614 rumor.tags.iter().find_map(|tag| {
615 let s = tag.as_slice();
616 if s.len() >= 2 && s[0] == "expiration" {
617 s[1].parse::<u64>().ok()
618 } else {
619 None
620 }
621 })
622}
623
624fn already_expired(expiration: Option<u64>) -> bool {
626 match expiration {
627 Some(exp) => std::time::SystemTime::now()
628 .duration_since(std::time::UNIX_EPOCH)
629 .map(|d| exp <= d.as_secs())
630 .unwrap_or(false),
631 None => false,
632 }
633}
634
635fn process_deletion(
636 rumor: RumorEvent,
637 _context: RumorContext,
638) -> Result<RumorProcessingResult, String> {
639 let target_event_id = unique_event_ref(&rumor)
640 .ok_or("Deletion target tag missing or ambiguous")?;
641 Ok(RumorProcessingResult::DeletionRequest { target_event_id })
642}
643
644fn is_renderable_reaction(content: &str) -> bool {
649 if content.is_empty() || content == "+" || content == "-" {
651 return true;
652 }
653 if let Some(inner) = content.strip_prefix(':').and_then(|s| s.strip_suffix(':')) {
656 if !inner.is_empty()
657 && inner.len() <= 48
658 && inner.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '~' | '-' | '+'))
659 {
660 return true;
661 }
662 }
663 content.chars().count() <= 12
667 && !content.chars().any(char::is_whitespace)
668 && !content.contains("://")
669}
670
671fn process_reaction(
675 rumor: RumorEvent,
676 _context: RumorContext,
677) -> Result<RumorProcessingResult, String> {
678 let reference_id = unique_event_ref(&rumor)
679 .ok_or("Reaction reference tag missing or ambiguous")?;
680
681 if !is_renderable_reaction(&rumor.content) {
684 return Ok(RumorProcessingResult::Ignored);
685 }
686
687 let emoji_url = if rumor.content.starts_with(':') && rumor.content.ends_with(':')
690 && rumor.content.len() >= 3
691 {
692 let sc = &rumor.content[1..rumor.content.len() - 1];
693 rumor.tags.iter().find_map(|tag| {
694 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
695 if parts.len() >= 3 && parts[0] == "emoji" && parts[1] == sc {
696 Some(parts[2].to_string())
697 } else {
698 None
699 }
700 })
701 } else {
702 None
703 };
704
705 let reaction = Reaction {
706 id: rumor.id.to_hex(),
707 reference_id,
708 author_id: rumor.pubkey.to_bech32().unwrap_or_else(|_| rumor.pubkey.to_hex()),
709 emoji: rumor.content,
710 emoji_url,
711 };
712
713 Ok(RumorProcessingResult::Reaction(reaction))
714}
715
716fn process_edit_event(
720 rumor: RumorEvent,
721 context: RumorContext,
722) -> Result<RumorProcessingResult, String> {
723 let message_id = unique_event_ref(&rumor)
724 .ok_or("Edit reference tag missing or ambiguous")?;
725
726 let edited_at = extract_millisecond_timestamp(&rumor);
727
728 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
731
732 let tags: Vec<Vec<String>> = rumor.tags.iter()
733 .map(|tag| {
734 tag.as_slice().iter().map(|s| s.to_string()).collect()
735 })
736 .collect();
737
738 let event = StoredEventBuilder::new()
739 .id(rumor.id.to_hex())
740 .kind(event_kind::MESSAGE_EDIT)
741 .content(rumor.content.clone())
742 .tags(tags)
743 .reference_id(Some(message_id.clone()))
744 .created_at(rumor.created_at.as_secs())
745 .mine(context.is_mine)
746 .npub(rumor.pubkey.to_bech32().ok())
747 .build();
748
749 Ok(RumorProcessingResult::Edit {
750 message_id,
751 new_content: rumor.content,
752 edited_at,
753 emoji_tags,
754 event,
755 })
756}
757
758fn process_app_specific(
760 rumor: RumorEvent,
761 context: RumorContext,
762) -> Result<RumorProcessingResult, String> {
763 if is_typing_indicator(&rumor) {
765 let expiry_tag = rumor.tags
766 .find_kind("expiration")
767 .ok_or("Typing indicator missing expiration tag")?;
768
769 let expiry_timestamp: u64 = expiry_tag.content()
770 .ok_or("Expiration tag has no content")?
771 .parse()
772 .map_err(|_| "Invalid expiration timestamp")?;
773
774 let current_timestamp = std::time::SystemTime::now()
775 .duration_since(std::time::UNIX_EPOCH)
776 .map_err(|e| format!("System time error: {}", e))?
777 .as_secs();
778
779 if expiry_timestamp <= current_timestamp || expiry_timestamp > current_timestamp + 30 {
780 return Ok(RumorProcessingResult::Ignored);
781 }
782
783 let profile_id = rumor.pubkey.to_bech32()
784 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
785
786 return Ok(RumorProcessingResult::TypingIndicator {
787 profile_id,
788 until: expiry_timestamp,
789 });
790 }
791
792 if is_leave_request(&rumor) {
794 let member_pubkey = rumor.pubkey.to_bech32()
795 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
796
797 return Ok(RumorProcessingResult::LeaveRequest {
798 event_id: rumor.id.to_hex(),
799 member_pubkey,
800 });
801 }
802
803 if is_pivx_payment(&rumor) {
805 let gift_code = rumor.tags
806 .find_kind("gift-code")
807 .and_then(|tag| tag.content())
808 .ok_or("PIVX payment missing gift-code tag")?
809 .to_string();
810
811 let amount_str = rumor.tags
812 .find_kind("amount")
813 .and_then(|tag| tag.content())
814 .unwrap_or("0");
815 let amount_piv = amount_str.parse::<u64>().unwrap_or(0) as f64 / 100_000_000.0;
816
817 let address = rumor.tags
818 .find_kind("address")
819 .and_then(|tag| tag.content())
820 .map(|s| s.to_string());
821
822 let message_id = rumor.id.to_hex();
823
824 let tags: Vec<Vec<String>> = rumor.tags.iter()
825 .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
826 .collect();
827
828 let event = StoredEventBuilder::new()
829 .id(&message_id)
830 .kind(event_kind::APPLICATION_SPECIFIC)
831 .chat_id(0) .content(&rumor.content)
833 .tags(tags)
834 .created_at(rumor.created_at.as_secs())
835 .mine(context.is_mine)
836 .npub(Some(rumor.pubkey.to_bech32().unwrap_or_default()))
837 .build();
838
839 return Ok(RumorProcessingResult::PivxPayment {
840 gift_code,
841 amount_piv,
842 address,
843 message_id,
844 event,
845 });
846 }
847
848 if is_wallpaper_change(&rumor) {
852 let url = rumor.tags
857 .find_kind("url")
858 .and_then(|tag| tag.content())
859 .unwrap_or_default()
860 .to_string();
861 let decryption_key = rumor.tags
862 .find_kind("decryption-key")
863 .and_then(|tag| tag.content())
864 .unwrap_or_default()
865 .to_string();
866 let decryption_nonce = rumor.tags
867 .find_kind("decryption-nonce")
868 .and_then(|tag| tag.content())
869 .unwrap_or_default()
870 .to_string();
871 let plaintext_hash = rumor.tags
872 .find_kind("x")
873 .and_then(|tag| tag.content())
874 .map(|s| s.to_string());
875 let mime = rumor.tags
876 .find_kind("m")
877 .and_then(|tag| tag.content())
878 .map(|s| s.to_string());
879 let blur = rumor.tags
880 .find_kind("blur")
881 .and_then(|tag| tag.content())
882 .and_then(|s| s.parse::<u32>().ok())
883 .map(|n| n.min(30) as u8);
884 let dim = rumor.tags
885 .find_kind("dim")
886 .and_then(|tag| tag.content())
887 .and_then(|s| s.parse::<u32>().ok())
888 .map(|n| n.min(100) as u8);
889
890 return Ok(RumorProcessingResult::WallpaperChanged {
891 sender_npub: rumor.pubkey.to_bech32().unwrap_or_default(),
892 created_at: rumor.created_at.as_secs(),
893 url,
894 decryption_key,
895 decryption_nonce,
896 plaintext_hash,
897 mime,
898 blur,
899 dim,
900 event_id: rumor.id.to_hex(),
901 });
902 }
903
904 if is_webxdc_peer_advertisement(&rumor) {
906 log_info!("[WEBXDC] Found peer advertisement rumor, is_mine={}, sender={}",
907 context.is_mine,
908 rumor.pubkey.to_bech32().unwrap_or_else(|_| "unknown".to_string()));
909
910 if context.is_mine {
911 log_info!("[WEBXDC] Ignoring our own peer advertisement");
912 return Ok(RumorProcessingResult::Ignored);
913 }
914
915 log_info!("[WEBXDC] Detected peer advertisement in rumor from another device");
916
917 let topic_id = rumor.tags
918 .find_kind("webxdc-topic")
919 .and_then(|tag| tag.content())
920 .ok_or("Peer advertisement missing webxdc-topic tag")?
921 .to_string();
922
923 let node_addr = rumor.tags
924 .find_kind("webxdc-node-addr")
925 .and_then(|tag| tag.content())
926 .ok_or("Peer advertisement missing webxdc-node-addr tag")?
927 .to_string();
928
929 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
930 return Ok(RumorProcessingResult::WebxdcPeerAdvertisement {
931 event_id: rumor.id.to_hex(),
932 topic_id,
933 node_addr,
934 sender_npub,
935 created_at: rumor.created_at.as_secs(),
936 });
937 }
938
939 if is_webxdc_peer_left(&rumor) {
941 if context.is_mine {
942 return Ok(RumorProcessingResult::Ignored);
943 }
944
945 log_info!("[WEBXDC] Detected peer-left signal from another device");
946
947 let topic_id = rumor.tags
948 .find_kind("webxdc-topic")
949 .and_then(|tag| tag.content())
950 .ok_or("Peer-left missing webxdc-topic tag")?
951 .to_string();
952
953 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
954 return Ok(RumorProcessingResult::WebxdcPeerLeft {
955 event_id: rumor.id.to_hex(),
956 topic_id,
957 sender_npub,
958 created_at: rumor.created_at.as_secs(),
959 });
960 }
961
962 Ok(RumorProcessingResult::Ignored)
964}
965
966fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool {
968 rumor.content == "peer-advertisement"
969 && rumor.tags.find_kind("webxdc-topic").is_some()
970 && rumor.tags.find_kind("webxdc-node-addr").is_some()
971}
972
973fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool {
975 rumor.content == "peer-left"
976 && rumor.tags.find_kind("webxdc-topic").is_some()
977}
978
979fn is_pivx_payment(rumor: &RumorEvent) -> bool {
981 rumor.tags
982 .find_kind("d")
983 .and_then(|tag| tag.content())
984 .map(|content| content == "pivx-payment")
985 .unwrap_or(false)
986 && rumor.tags.find_kind("gift-code").is_some()
987}
988
989fn extract_millisecond_timestamp(rumor: &RumorEvent) -> u64 {
998 let ms_tag = rumor.tags
999 .find_kind("ms")
1000 .and_then(|t| t.content());
1001 resolve_message_timestamp(rumor.created_at.as_secs(), ms_tag)
1002}
1003
1004pub fn resolve_message_timestamp(created_at_secs: u64, ms_tag: Option<&str>) -> u64 {
1015 const FUTURE_GRACE_MS: u64 = 5 * 60 * 1000;
1016 let base = created_at_secs.saturating_mul(1000);
1017 let at = match ms_tag.and_then(|s| s.parse::<u64>().ok()) {
1018 Some(offset) if offset <= 999 => base.saturating_add(offset),
1019 _ => base,
1020 };
1021 let now_ms = std::time::SystemTime::now()
1022 .duration_since(std::time::UNIX_EPOCH)
1023 .map(|d| d.as_millis() as u64)
1024 .unwrap_or(u64::MAX);
1025 if at > now_ms.saturating_add(FUTURE_GRACE_MS) { now_ms } else { at }
1026}
1027
1028fn extract_reply_reference(rumor: &RumorEvent) -> String {
1033 match rumor.tags.find_kind("e") {
1034 Some(tag) if tag.as_slice().get(3).is_some_and(|s| s == "reply") => {
1036 tag.content().unwrap_or("").to_string()
1037 }
1038 _ => String::new(),
1039 }
1040}
1041
1042fn is_typing_indicator(rumor: &RumorEvent) -> bool {
1044 let has_vector_tag = rumor.tags
1045 .find_kind("d")
1046 .and_then(|tag| tag.content())
1047 .map(|content| content == "vector")
1048 .unwrap_or(false);
1049
1050 let is_typing_content = rumor.content == "typing";
1051
1052 has_vector_tag && is_typing_content
1053}
1054
1055fn is_wallpaper_change(rumor: &RumorEvent) -> bool {
1057 rumor.tags
1058 .find_kind("d")
1059 .and_then(|tag| tag.content())
1060 .map(|content| content == "vector-wallpaper")
1061 .unwrap_or(false)
1062}
1063
1064fn is_leave_request(rumor: &RumorEvent) -> bool {
1066 let has_vector_tag = rumor.tags
1067 .find_kind("d")
1068 .and_then(|tag| tag.content())
1069 .map(|content| content == "vector")
1070 .unwrap_or(false);
1071
1072 let is_leave_content = rumor.content == "leave";
1073
1074 has_vector_tag && is_leave_content
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080
1081 #[test]
1085 fn ms_resolver_applies_offset_enforces_sub_second_and_clamps_future() {
1086 assert_eq!(resolve_message_timestamp(1500, Some("242")), 1_500_242);
1088 assert_eq!(resolve_message_timestamp(1500, None), 1_500_000);
1090 assert_eq!(resolve_message_timestamp(1500, Some("4242")), 1_500_000);
1092 assert_eq!(resolve_message_timestamp(1500, Some("nope")), 1_500_000);
1093 let now = std::time::SystemTime::now()
1095 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1096 let clamped = resolve_message_timestamp(253_402_300_800, Some("5"));
1097 assert!(clamped <= (now + 3600) * 1000, "implausible-future ms must clamp to ~now");
1098 }
1099
1100 #[test]
1104 fn ambiguous_target_is_rejected_for_reaction_edit_delete() {
1105 let keys = test_keypair();
1106 let two_e = || tags(vec![
1107 Tag::custom("e", ["aa".repeat(32)]),
1108 Tag::custom("e", ["bb".repeat(32)]),
1109 ]);
1110 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", two_e()), dm_context(&keys), &temp_dir()).is_err());
1111 assert!(process_rumor(make_rumor(&keys, Kind::EventDeletion, "", two_e()), dm_context(&keys), &temp_dir()).is_err());
1112 assert!(process_rumor(make_rumor(&keys, Kind::from(event_kind::MESSAGE_EDIT), "edited", two_e()), dm_context(&keys), &temp_dir()).is_err());
1113 let one_e = tags(vec![Tag::custom("e", ["aa".repeat(32)])]);
1114 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", one_e), dm_context(&keys), &temp_dir()).is_ok());
1115 }
1116
1117 fn test_keypair() -> Keys {
1118 Keys::generate()
1119 }
1120
1121 fn tags(items: Vec<Tag>) -> Tags {
1123 let mut t = Tags::new();
1124 for item in items {
1125 t.push(item);
1126 }
1127 t
1128 }
1129
1130 fn custom_tag(key: &str, values: &[&str]) -> Tag {
1132 let owned: Vec<String> = values.iter().map(|s| s.to_string()).collect();
1133 Tag::custom(key.to_string(), owned)
1134 }
1135
1136 fn make_rumor(keys: &Keys, kind: Kind, content: &str, t: Tags) -> RumorEvent {
1137 RumorEvent {
1138 id: EventId::from_byte_array([0u8; 32]),
1139 kind,
1140 content: content.to_string(),
1141 tags: t,
1142 created_at: Timestamp::from_secs(1700000000),
1143 pubkey: keys.public_key(),
1144 }
1145 }
1146
1147 fn dm_context(keys: &Keys) -> RumorContext {
1148 RumorContext {
1149 sender: keys.public_key(),
1150 is_mine: false,
1151 conversation_id: "npub1test".to_string(),
1152 conversation_type: ConversationType::DirectMessage,
1153 }
1154 }
1155
1156 fn temp_dir() -> std::path::PathBuf {
1157 std::env::temp_dir().join("vector-rumor-test")
1158 }
1159
1160 #[test]
1165 fn test_text_message_dm() {
1166 let keys = test_keypair();
1167 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Hello world!", Tags::new());
1168 let ctx = dm_context(&keys);
1169 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1170
1171 match result {
1172 RumorProcessingResult::TextMessage(msg) => {
1173 assert_eq!(msg.content, "Hello world!");
1174 assert!(!msg.mine);
1175 assert!(msg.npub.is_none());
1176 assert!(msg.attachments.is_empty());
1177 }
1178 _ => panic!("Expected TextMessage"),
1179 }
1180 }
1181
1182 #[test]
1183 fn a_kind_14_imeta_attachment_is_a_file_not_a_link() {
1184 let keys = test_keypair();
1191 let url = "https://blossom.ditto.pub/80f94026dc4fc97f59d131d0f6ce9af1951602f720a9957d6d7189fc1aa4ffcf.m4a";
1192 let imeta = Tag::custom(
1193 "imeta",
1194 vec![
1195 format!("url {url}"),
1196 "m audio/mp4".to_string(),
1197 "waveform 2 100 83 100 2 100 100 100".to_string(),
1198 "duration 58".to_string(),
1199 "encryption-algorithm aes-gcm".to_string(),
1200 "decryption-key 6989b3b663e29897cbb8bbaeda93d7f02d54c5551be9b4fda7aa7cd788a4b7f0".to_string(),
1201 "decryption-nonce d45c254d936ce9e9108e8d7a4e88834c".to_string(),
1202 "ox 5276b12eaa5019742abe86508d0a1ce6ee704603dc384048d0f00fa80b8d7eb8".to_string(),
1203 ],
1204 );
1205 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, url, Tags::from_list(vec![imeta]));
1206 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1207
1208 match result {
1209 RumorProcessingResult::TextMessage(msg) => {
1210 assert_eq!(msg.attachments.len(), 1, "the imeta becomes a real attachment");
1211 let att = &msg.attachments[0];
1212 assert_eq!(att.url, url);
1213 assert_eq!(att.extension, "m4a", "extension resolves from the audio/mp4 mime");
1214 assert_eq!(att.key, "6989b3b663e29897cbb8bbaeda93d7f02d54c5551be9b4fda7aa7cd788a4b7f0");
1215 assert_eq!(att.nonce, "d45c254d936ce9e9108e8d7a4e88834c");
1216 assert_eq!(
1217 att.original_hash.as_deref(),
1218 Some("5276b12eaa5019742abe86508d0a1ce6ee704603dc384048d0f00fa80b8d7eb8"),
1219 "ox is the dedup identity"
1220 );
1221 assert!(!att.downloaded, "arrival never claims the bytes are held");
1222 assert!(msg.content.is_empty(), "the URL renders as the file, not also as a link");
1223 }
1224 _ => panic!("Expected TextMessage carrying an attachment"),
1225 }
1226 }
1227
1228 #[test]
1229 fn a_kind_14_caption_survives_beside_its_attachment() {
1230 let keys = test_keypair();
1232 let url = "https://blossom.example/abc.png";
1233 let imeta = Tag::custom(
1234 "imeta",
1235 vec![format!("url {url}"), "m image/png".to_string()],
1236 );
1237 let rumor = make_rumor(
1238 &keys,
1239 Kind::PrivateDirectMessage,
1240 &format!("look at this\n{url}"),
1241 Tags::from_list(vec![imeta]),
1242 );
1243 match process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap() {
1244 RumorProcessingResult::TextMessage(msg) => {
1245 assert_eq!(msg.content, "look at this");
1246 assert_eq!(msg.attachments.len(), 1);
1247 assert!(msg.attachments[0].key.is_empty());
1249 }
1250 _ => panic!("Expected TextMessage"),
1251 }
1252 }
1253
1254 #[test]
1255 fn test_text_message_mine() {
1256 let keys = test_keypair();
1257 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "My own message", Tags::new());
1258 let ctx = RumorContext {
1259 sender: keys.public_key(),
1260 is_mine: true,
1261 conversation_id: "npub1test".to_string(),
1262 conversation_type: ConversationType::DirectMessage,
1263 };
1264 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1265
1266 match result {
1267 RumorProcessingResult::TextMessage(msg) => {
1268 assert!(msg.mine);
1269 }
1270 _ => panic!("Expected TextMessage"),
1271 }
1272 }
1273
1274 #[test]
1275 fn test_text_message_with_reply() {
1276 let keys = test_keypair();
1277 let t = tags(vec![
1278 Tag::custom("e", ["abc123def456".to_string(), String::new(), "reply".to_string()]),
1279 ]);
1280 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Reply text", t);
1281 let ctx = dm_context(&keys);
1282 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1283
1284 match result {
1285 RumorProcessingResult::TextMessage(msg) => {
1286 assert_eq!(msg.replied_to, "abc123def456");
1287 }
1288 _ => panic!("Expected TextMessage"),
1289 }
1290 }
1291
1292 #[test]
1293 fn test_text_message_with_ms_timestamp() {
1294 let keys = test_keypair();
1295 let t = tags(vec![custom_tag("ms", &["456"])]);
1296 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Precise time", t);
1297 let ctx = dm_context(&keys);
1298 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1299
1300 match result {
1301 RumorProcessingResult::TextMessage(msg) => {
1302 assert_eq!(msg.at, 1700000000 * 1000 + 456);
1303 }
1304 _ => panic!("Expected TextMessage"),
1305 }
1306 }
1307
1308 #[test]
1313 fn test_reaction() {
1314 let keys = test_keypair();
1315 let t = tags(vec![custom_tag("e", &["target_msg_id_hex"])]);
1316 let rumor = make_rumor(&keys, Kind::Reaction, "👍", t);
1317 let ctx = dm_context(&keys);
1318 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1319
1320 match result {
1321 RumorProcessingResult::Reaction(reaction) => {
1322 assert_eq!(reaction.emoji, "👍");
1323 assert_eq!(reaction.reference_id, "target_msg_id_hex");
1324 }
1325 _ => panic!("Expected Reaction"),
1326 }
1327 }
1328
1329 #[test]
1330 fn test_reaction_missing_e_tag() {
1331 let keys = test_keypair();
1332 let rumor = make_rumor(&keys, Kind::Reaction, "👍", Tags::new());
1333 let ctx = dm_context(&keys);
1334 let result = process_rumor(rumor, ctx, &temp_dir());
1335 assert!(result.is_err());
1336 }
1337
1338 #[test]
1339 fn junk_reaction_content_is_dropped_clean_ones_kept() {
1340 for ok in ["👍", "+", "-", "", "👨\u{200d}👩\u{200d}👧\u{200d}👦", ":thugamy:"] {
1342 assert!(is_renderable_reaction(ok), "{ok:?} should be renderable");
1343 }
1344 for junk in [
1346 ":thugamy:https://image.nostr.build/ccc22.png",
1347 "https://example.com/x.png",
1348 "lorem ipsum dolor",
1349 ] {
1350 assert!(!is_renderable_reaction(junk), "{junk:?} should be dropped");
1351 }
1352 assert!(!is_renderable_reaction(&"x".repeat(64)));
1353
1354 let keys = test_keypair();
1356 let t = tags(vec![custom_tag("e", &["target"])]);
1357 let rumor = make_rumor(&keys, Kind::Reaction, ":thugamy:https://image.nostr.build/ccc22.png", t);
1358 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1359 assert!(matches!(result, RumorProcessingResult::Ignored), "junk reaction should be Ignored");
1360 }
1361
1362 #[test]
1367 fn test_edit_event() {
1368 let keys = test_keypair();
1369 let t = tags(vec![custom_tag("e", &["original_msg_id"])]);
1370 let rumor = make_rumor(&keys, Kind::from_u16(16), "Edited content", t);
1371 let ctx = dm_context(&keys);
1372 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1373
1374 match result {
1375 RumorProcessingResult::Edit { message_id, new_content, event, .. } => {
1376 assert_eq!(message_id, "original_msg_id");
1377 assert_eq!(new_content, "Edited content");
1378 assert_eq!(event.kind, event_kind::MESSAGE_EDIT);
1379 }
1380 _ => panic!("Expected Edit"),
1381 }
1382 }
1383
1384 #[test]
1389 fn test_typing_indicator_valid() {
1390 let keys = test_keypair();
1391 let future_ts = std::time::SystemTime::now()
1392 .duration_since(std::time::UNIX_EPOCH).unwrap()
1393 .as_secs() + 10;
1394 let t = tags(vec![
1395 Tag::identifier("vector"),
1396 Tag::expiration(Timestamp::from_secs(future_ts)),
1397 ]);
1398 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1399 let ctx = dm_context(&keys);
1400 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1401
1402 match result {
1403 RumorProcessingResult::TypingIndicator { until, .. } => {
1404 assert_eq!(until, future_ts);
1405 }
1406 _ => panic!("Expected TypingIndicator"),
1407 }
1408 }
1409
1410 #[test]
1411 fn test_typing_indicator_expired() {
1412 let keys = test_keypair();
1413 let t = tags(vec![
1414 Tag::identifier("vector"),
1415 Tag::expiration(Timestamp::from_secs(1000000000)),
1416 ]);
1417 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1418 let ctx = dm_context(&keys);
1419 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1420
1421 assert!(matches!(result, RumorProcessingResult::Ignored));
1422 }
1423
1424 #[test]
1429 fn test_expired_text_message_is_dropped_on_receipt() {
1430 let keys = test_keypair();
1431 let t = tags(vec![Tag::expiration(Timestamp::from_secs(1000000000))]);
1432 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "too late", t);
1433 let ctx = dm_context(&keys);
1434 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1435 assert!(matches!(result, RumorProcessingResult::Ignored));
1436 }
1437
1438 #[test]
1439 fn test_expired_file_message_is_dropped_on_receipt() {
1440 let keys = test_keypair();
1441 let ox_hash = "deadbeef".repeat(8);
1442 let t = tags(vec![
1443 custom_tag("decryption-key", &["aabbccdd"]),
1444 custom_tag("decryption-nonce", &["11223344"]),
1445 custom_tag("ox", &[&ox_hash]),
1446 custom_tag("file-type", &["image/jpeg"]),
1447 Tag::expiration(Timestamp::from_secs(1000000000)),
1448 ]);
1449 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1450 let ctx = dm_context(&keys);
1451 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1452 assert!(matches!(result, RumorProcessingResult::Ignored));
1453 }
1454
1455 #[test]
1456 fn test_future_expiration_still_processes() {
1457 let keys = test_keypair();
1458 let future_ts = std::time::SystemTime::now()
1459 .duration_since(std::time::UNIX_EPOCH).unwrap()
1460 .as_secs() + 600;
1461 let t = tags(vec![Tag::expiration(Timestamp::from_secs(future_ts))]);
1462 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "still alive", t);
1463 let ctx = dm_context(&keys);
1464 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1465 match result {
1466 RumorProcessingResult::TextMessage(msg) => {
1467 assert_eq!(msg.expiration, Some(future_ts));
1468 }
1469 _ => panic!("Expected TextMessage"),
1470 }
1471 }
1472
1473 #[test]
1478 fn test_leave_request() {
1479 let keys = test_keypair();
1480 let t = tags(vec![Tag::identifier("vector")]);
1481 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "leave", t);
1482 let ctx = dm_context(&keys);
1483 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1484
1485 match result {
1486 RumorProcessingResult::LeaveRequest { member_pubkey, .. } => {
1487 assert!(!member_pubkey.is_empty());
1488 assert!(member_pubkey.starts_with("npub1"));
1489 }
1490 _ => panic!("Expected LeaveRequest"),
1491 }
1492 }
1493
1494 #[test]
1499 fn test_pivx_payment() {
1500 let keys = test_keypair();
1501 let t = tags(vec![
1502 Tag::identifier("pivx-payment"),
1503 custom_tag("gift-code", &["ABC12"]),
1504 custom_tag("amount", &["100000000"]),
1505 custom_tag("address", &["DTest123"]),
1506 ]);
1507 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "pivx-payment", t);
1508 let ctx = dm_context(&keys);
1509 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1510
1511 match result {
1512 RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, .. } => {
1513 assert_eq!(gift_code, "ABC12");
1514 assert!((amount_piv - 1.0).abs() < f64::EPSILON);
1515 assert_eq!(address, Some("DTest123".to_string()));
1516 }
1517 _ => panic!("Expected PivxPayment"),
1518 }
1519 }
1520
1521 #[test]
1526 fn test_webxdc_peer_advertisement() {
1527 let keys = test_keypair();
1528 let t = tags(vec![
1529 custom_tag("webxdc-topic", &["topic123"]),
1530 custom_tag("webxdc-node-addr", &["addr456"]),
1531 ]);
1532 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1533 let ctx = dm_context(&keys);
1534 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1535
1536 match result {
1537 RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr, .. } => {
1538 assert_eq!(topic_id, "topic123");
1539 assert_eq!(node_addr, "addr456");
1540 }
1541 _ => panic!("Expected WebxdcPeerAdvertisement"),
1542 }
1543 }
1544
1545 #[test]
1546 fn test_webxdc_peer_advertisement_own_ignored() {
1547 let keys = test_keypair();
1548 let t = tags(vec![
1549 custom_tag("webxdc-topic", &["topic123"]),
1550 custom_tag("webxdc-node-addr", &["addr456"]),
1551 ]);
1552 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1553 let ctx = RumorContext {
1554 sender: keys.public_key(),
1555 is_mine: true,
1556 conversation_id: "npub1test".to_string(),
1557 conversation_type: ConversationType::DirectMessage,
1558 };
1559 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1560 assert!(matches!(result, RumorProcessingResult::Ignored));
1561 }
1562
1563 #[test]
1564 fn test_webxdc_peer_left() {
1565 let keys = test_keypair();
1566 let t = tags(vec![custom_tag("webxdc-topic", &["topic123"])]);
1567 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-left", t);
1568 let ctx = dm_context(&keys);
1569 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1570
1571 match result {
1572 RumorProcessingResult::WebxdcPeerLeft { topic_id, .. } => {
1573 assert_eq!(topic_id, "topic123");
1574 }
1575 _ => panic!("Expected WebxdcPeerLeft"),
1576 }
1577 }
1578
1579 #[test]
1584 fn test_unknown_kind() {
1585 let keys = test_keypair();
1586 let rumor = make_rumor(&keys, Kind::from_u16(65535), "Mystery event", Tags::new());
1587 let ctx = dm_context(&keys);
1588 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1589
1590 match result {
1591 RumorProcessingResult::UnknownEvent(event) => {
1592 assert_eq!(event.kind, 65535);
1593 assert_eq!(event.content, "Mystery event");
1594 }
1595 _ => panic!("Expected UnknownEvent"),
1596 }
1597 }
1598
1599 #[test]
1604 fn test_file_attachment() {
1605 let keys = test_keypair();
1606 let ox_hash = "deadbeef".repeat(8); let t = tags(vec![
1608 custom_tag("decryption-key", &["aabbccdd"]),
1609 custom_tag("decryption-nonce", &["11223344"]),
1610 custom_tag("ox", &[&ox_hash]),
1611 custom_tag("file-type", &["image/jpeg"]),
1612 custom_tag("name", &["photo.jpg"]),
1613 custom_tag("size", &["12345"]),
1614 ]);
1615 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1616 let ctx = dm_context(&keys);
1617 let dir = temp_dir();
1618 let result = process_rumor(rumor, ctx, &dir).unwrap();
1619
1620 match result {
1621 RumorProcessingResult::FileAttachment(msg) => {
1622 assert_eq!(msg.attachments.len(), 1);
1623 let att = &msg.attachments[0];
1624 assert_eq!(att.key, "aabbccdd");
1625 assert_eq!(att.nonce, "11223344");
1626 assert_eq!(att.extension, "jpg");
1627 assert_eq!(att.name, "photo.jpg");
1628 assert_eq!(att.size, 12345);
1629 assert!(!att.downloaded);
1630 }
1631 _ => panic!("Expected FileAttachment"),
1632 }
1633 }
1634
1635 #[test]
1636 fn test_file_attachment_fallback_mirrors() {
1637 let keys = test_keypair();
1638 let ox_hash = "deadbeef".repeat(8);
1639 let t = tags(vec![
1640 custom_tag("decryption-key", &["aabbccdd"]),
1641 custom_tag("decryption-nonce", &["11223344"]),
1642 custom_tag("ox", &[&ox_hash]),
1643 custom_tag("file-type", &["image/jpeg"]),
1644 custom_tag("fallback", &["https://mirror-one.example/deadbeef.jpg"]),
1645 custom_tag("fallback", &["https://blossom.example/deadbeef.jpg"]),
1648 custom_tag("fallback", &["http://insecure.example/deadbeef.jpg"]),
1649 custom_tag("fallback", &["https://sneaky.example/a b.jpg"]),
1650 custom_tag("fallback", &["https://mirror-one.example/deadbeef.jpg"]),
1651 custom_tag("fallback", &["https://mirror-two.example/deadbeef.jpg"]),
1652 ]);
1653 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1654 let ctx = dm_context(&keys);
1655 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1656
1657 match result {
1658 RumorProcessingResult::FileAttachment(msg) => {
1659 let att = &msg.attachments[0];
1660 assert_eq!(att.fallback_urls, vec![
1661 "https://mirror-one.example/deadbeef.jpg".to_string(),
1662 "https://mirror-two.example/deadbeef.jpg".to_string(),
1663 ]);
1664 }
1665 _ => panic!("Expected FileAttachment"),
1666 }
1667 }
1668
1669 #[test]
1670 fn test_file_attachment_hostile_path_basis_rejected() {
1671 let keys = test_keypair();
1672 let dir = temp_dir();
1673 let ctx = || dm_context(&keys);
1674
1675 let t = tags(vec![
1679 custom_tag("decryption-key", &["aabbccdd"]),
1680 custom_tag("decryption-nonce", &["11223344"]),
1681 custom_tag("ox", &["../../../etc/passwd"]),
1682 custom_tag("file-type", &["image/jpeg"]),
1683 custom_tag("name", &["x.jpg"]),
1684 ]);
1685 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/x.jpg", t);
1686 let expected_id = crate::crypto::attachment_identity_basis(None, "11223344", "https://blossom.example/x.jpg");
1687 match process_rumor(rumor, ctx(), &dir).unwrap() {
1688 RumorProcessingResult::FileAttachment(msg) => {
1689 let att = &msg.attachments[0];
1690 assert!(!att.path.contains(".."), "traversal basis must not reach the path: {}", att.path);
1691 assert_eq!(att.id, expected_id, "id falls back to the nonce+url digest");
1692 }
1693 _ => panic!("Expected FileAttachment"),
1694 }
1695
1696 let t = tags(vec![
1698 custom_tag("decryption-key", &["aabbccdd"]),
1699 custom_tag("decryption-nonce", &["../../../etc/cron.d/evil"]),
1700 custom_tag("file-type", &["image/jpeg"]),
1701 ]);
1702 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/y.jpg", t);
1703 assert!(process_rumor(rumor, ctx(), &dir).is_err());
1704 }
1705
1706 #[test]
1707 fn test_file_attachment_empty_hash_rejected() {
1708 let keys = test_keypair();
1709 let t = tags(vec![
1710 custom_tag("decryption-key", &["aabbccdd"]),
1711 custom_tag("decryption-nonce", &["11223344"]),
1712 custom_tag("file-type", &["image/jpeg"]),
1713 ]);
1714 let empty_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1715 let rumor = make_rumor(&keys, Kind::from_u16(15), &format!("https://blossom.example/{}", empty_hash), t);
1716 let ctx = dm_context(&keys);
1717 let result = process_rumor(rumor, ctx, &temp_dir());
1718 assert!(result.is_err());
1719 }
1720
1721 #[test]
1722 fn test_file_attachment_with_image_meta() {
1723 let keys = test_keypair();
1724 let ox_hash = "a".repeat(64);
1725 let t = tags(vec![
1726 custom_tag("decryption-key", &["aabbccdd"]),
1727 custom_tag("decryption-nonce", &["11223344"]),
1728 custom_tag("ox", &[&ox_hash]),
1729 custom_tag("file-type", &["image/png"]),
1730 custom_tag("thumbhash", &["base64data"]),
1731 custom_tag("dim", &["1920x1080"]),
1732 custom_tag("size", &["5000"]),
1733 ]);
1734 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/aaa.png", t);
1735 let ctx = dm_context(&keys);
1736 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1737
1738 match result {
1739 RumorProcessingResult::FileAttachment(msg) => {
1740 let att = &msg.attachments[0];
1741 let meta = att.img_meta.as_ref().unwrap();
1742 assert_eq!(meta.width, 1920);
1743 assert_eq!(meta.height, 1080);
1744 assert_eq!(meta.thumbhash, "base64data");
1745 }
1746 _ => panic!("Expected FileAttachment"),
1747 }
1748 }
1749
1750 #[test]
1756 fn test_file_attachment_thumb_tag_is_read() {
1757 let keys = test_keypair();
1758 let ox_hash = "b".repeat(64);
1759 let t = tags(vec![
1760 custom_tag("decryption-key", &["aabbccdd"]),
1761 custom_tag("decryption-nonce", &["11223344"]),
1762 custom_tag("ox", &[&ox_hash]),
1763 custom_tag("file-type", &["image/png"]),
1764 custom_tag("thumb", &["realwiretag"]),
1765 custom_tag("dim", &["800x600"]),
1766 custom_tag("size", &["5000"]),
1767 ]);
1768 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/bbb.png", t);
1769 let ctx = dm_context(&keys);
1770 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1771
1772 match result {
1773 RumorProcessingResult::FileAttachment(msg) => {
1774 let meta = msg.attachments[0].img_meta.as_ref()
1775 .expect("img_meta must be populated from the `thumb` tag");
1776 assert_eq!(meta.thumbhash, "realwiretag");
1777 assert_eq!(meta.width, 800);
1778 assert_eq!(meta.height, 600);
1779 }
1780 _ => panic!("Expected FileAttachment"),
1781 }
1782 }
1783
1784 #[test]
1789 fn test_extract_hash_from_blossom_url() {
1790 let hash = "a".repeat(64);
1791 let url = format!("https://blossom.example/{}.jpg", hash);
1792 assert_eq!(extract_hash_from_blossom_url(&url), Some(hash));
1793
1794 assert_eq!(extract_hash_from_blossom_url("https://example.com/short"), None);
1795 assert_eq!(extract_hash_from_blossom_url("https://example.com/not-hex-at-all-but-exactly-sixty-four-characters-long-string-here!"), None);
1796 }
1797
1798 #[test]
1799 fn test_unknown_app_specific_ignored() {
1800 let keys = test_keypair();
1801 let t = tags(vec![Tag::identifier("some-other-app")]);
1802 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "unknown-content", t);
1803 let ctx = dm_context(&keys);
1804 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1805 assert!(matches!(result, RumorProcessingResult::Ignored));
1806 }
1807}