1use std::path::Path;
10use nostr_sdk::prelude::*;
11use crate::types::{Attachment, ImageMetadata};
12
13const IMETA: &str = "imeta";
14
15pub fn attachment_to_imeta(att: &Attachment) -> Tag {
19 let mut fields: Vec<String> = Vec::with_capacity(10);
20 fields.push(format!("url {}", att.url));
21 fields.push(format!("m {}", crate::crypto::mime_from_extension(&att.extension)));
22 fields.push("encryption-algorithm aes-gcm".to_string());
23 fields.push(format!("decryption-key {}", att.key));
24 fields.push(format!("decryption-nonce {}", att.nonce));
25 if att.size > 0 {
26 fields.push(format!("size {}", att.size));
27 }
28 if let Some(h) = att.original_hash.as_deref().filter(|h| !h.is_empty()) {
29 fields.push(format!("ox {}", h));
30 }
31 if !att.name.is_empty() {
32 fields.push(format!("name {}", att.name));
33 }
34 if let Some(meta) = &att.img_meta {
35 if !meta.thumbhash.is_empty() {
36 fields.push(format!("thumb {}", meta.thumbhash));
37 }
38 fields.push(format!("dim {}x{}", meta.width, meta.height));
39 }
40 if let Some(topic) = att.webxdc_topic.as_deref().filter(|t| !t.is_empty()) {
43 fields.push(format!("webxdc-topic {}", topic));
44 }
45 for fb in &att.fallback_urls {
47 fields.push(format!("fallback {}", fb));
48 }
49 Tag::custom(IMETA, fields)
50}
51
52fn field<'a>(entries: &'a [String], key: &str) -> Option<&'a str> {
55 entries.iter().find_map(|e| {
56 e.strip_prefix(key)
57 .and_then(|rest| rest.strip_prefix(' '))
58 })
59}
60
61fn fields_all<'a>(entries: &'a [String], key: &str) -> impl Iterator<Item = &'a str> + 'a {
63 let key = key.to_string();
64 entries.iter().filter_map(move |e| {
65 e.strip_prefix(key.as_str())
66 .and_then(|rest| rest.strip_prefix(' '))
67 })
68}
69
70pub fn attachment_from_imeta(tag: &Tag, download_dir: &Path) -> Option<Attachment> {
74 let entries = tag.as_slice();
75 if entries.first().map(String::as_str) != Some(IMETA) {
76 return None;
77 }
78 let body = &entries[1..];
79
80 let url = field(body, "url")?.to_string();
81 if url.is_empty() {
82 return None;
83 }
84 let key = field(body, "decryption-key").unwrap_or("").to_string();
88 let nonce = field(body, "decryption-nonce").unwrap_or("").to_string();
89 if key.is_empty() != nonce.is_empty() {
91 return None;
92 }
93 let encrypted = !key.is_empty();
94
95 let mime = field(body, "m").unwrap_or("application/octet-stream");
96 let name = field(body, "name").map(crate::crypto::sanitize_filename).unwrap_or_default();
97 let extension = name
100 .rsplit('.')
101 .next()
102 .filter(|e| !e.is_empty() && *e != name)
103 .map(|e| e.to_lowercase())
104 .unwrap_or_else(|| crate::crypto::extension_from_mime(mime));
105
106 let size = field(body, "size").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
107 let original_hash = field(body, "ox").or_else(|| field(body, "x"))
110 .map(|s| s.to_string()).filter(|s| !s.is_empty());
111
112 let img_meta = {
113 let thumb = field(body, "thumb").map(|s| s.to_string());
114 let dim = field(body, "dim").and_then(|s| {
115 let (w, h) = s.split_once('x')?;
116 Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?))
117 });
118 match (thumb, dim) {
119 (Some(thumbhash), Some((width, height))) => Some(ImageMetadata { thumbhash, width, height }),
120 _ => None,
121 }
122 };
123
124 if encrypted && (nonce.len() > 128 || !nonce.bytes().all(|b| b.is_ascii_hexdigit())) {
128 return None;
129 }
130
131 let basis = crate::crypto::attachment_identity_basis(original_hash.as_deref(), &nonce, &url);
138 if basis.is_empty() || basis.len() > 128 || !basis.bytes().all(|b| b.is_ascii_hexdigit()) {
139 return None;
140 }
141 let path = download_dir.join(format!("{}.{}", basis, extension));
142 let downloaded = false;
147
148 let webxdc_topic = field(body, "webxdc-topic")
151 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
152 .map(|t| t.to_string());
153
154 let mut fallback_urls: Vec<String> = Vec::new();
157 for fb in fields_all(body, "fallback") {
158 if !fb.starts_with("https://")
159 || fb.contains(char::is_whitespace)
160 || fb == url
161 || fallback_urls.iter().any(|f| f == fb)
162 {
163 continue;
164 }
165 fallback_urls.push(fb.to_string());
166 if fallback_urls.len() >= 4 {
167 break;
168 }
169 }
170
171 Some(Attachment {
172 id: basis,
173 key,
174 nonce,
175 extension,
176 name,
177 url,
178 path: path.to_string_lossy().to_string(),
179 size,
180 img_meta,
181 downloading: false,
182 downloaded,
183 webxdc_topic,
184 group_id: None, original_hash,
186 fallback_urls,
187 })
188}
189
190pub fn attachments_from_tags<'a>(
194 tags: impl Iterator<Item = &'a Tag>,
195 download_dir: &Path,
196) -> Vec<Attachment> {
197 const MAX_ATTACHMENTS_PER_MESSAGE: usize = 32;
198 tags.filter_map(|t| attachment_from_imeta(t, download_dir))
199 .take(MAX_ATTACHMENTS_PER_MESSAGE)
200 .collect()
201}
202
203pub fn strip_attachment_urls(content: &str, attachments: &[Attachment]) -> String {
210 if content.is_empty() || attachments.is_empty() {
211 return content.to_string();
212 }
213 let mut out = content.to_string();
214 for att in attachments {
215 if !att.url.is_empty() {
216 out = out.replace(&att.url, "");
217 }
218 }
219 if out == content {
220 return content.to_string(); }
222 out.lines()
226 .map(str::trim_end)
227 .collect::<Vec<_>>()
228 .join("\n")
229 .trim()
230 .to_string()
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn sample(name: &str, ext: &str, with_img: bool) -> Attachment {
238 Attachment {
239 id: "h".into(),
240 key: "0".repeat(64), nonce: "1".repeat(32), extension: ext.into(),
243 name: name.into(),
244 url: "https://blossom.example/abc".into(),
245 path: String::new(),
246 size: 4096,
247 img_meta: with_img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 800, height: 600 }),
248 downloading: false,
249 downloaded: false,
250 webxdc_topic: None,
251 group_id: None,
252 original_hash: Some("a".repeat(64)),
253 fallback_urls: Vec::new(),
254 }
255 }
256
257 #[test]
258 fn imeta_fallback_mirrors_roundtrip() {
259 let dir = std::env::temp_dir();
260 let mut att = sample("pic.png", "png", false);
261 att.fallback_urls = vec![
262 "https://mirror-one.example/abc".to_string(),
263 "https://mirror-two.example/abc".to_string(),
264 ];
265 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), &dir).unwrap();
266 assert_eq!(parsed.fallback_urls, att.fallback_urls);
267 }
268
269 #[test]
270 fn imeta_fallback_filters_junk() {
271 let dir = std::env::temp_dir();
274 let att = sample("pic.png", "png", false);
275 let mut entries: Vec<String> = attachment_to_imeta(&att).as_slice()[1..].to_vec();
276 entries.push(format!("fallback {}", att.url));
277 entries.push("fallback http://insecure.example/abc".to_string());
278 entries.push("fallback https://sneaky.example/a b".to_string());
279 entries.push("fallback https://mirror.example/abc".to_string());
280 entries.push("fallback https://mirror.example/abc".to_string());
281 let parsed = attachment_from_imeta(&Tag::custom(IMETA, entries), &dir).unwrap();
282 assert_eq!(parsed.fallback_urls, vec!["https://mirror.example/abc".to_string()]);
283 }
284
285 #[test]
286 fn strip_attachment_urls_removes_inlined_blob_url() {
287 let att = sample("pic.jpeg", "jpeg", false); assert_eq!(
290 strip_attachment_urls("Check this out\nhttps://blossom.example/abc", &[att.clone()]),
291 "Check this out"
292 );
293 assert_eq!(strip_attachment_urls("https://blossom.example/abc", &[att.clone()]), "");
295 assert_eq!(strip_attachment_urls("just a caption", &[att.clone()]), "just a caption");
297 assert_eq!(strip_attachment_urls("hello", &[]), "hello");
299 }
300
301 #[test]
302 fn nonce_reuse_yields_distinct_identities() {
303 let dir = std::env::temp_dir();
307 let mut a = sample("", "png", false);
308 a.original_hash = None;
309 let mut b = sample("", "png", false);
310 b.original_hash = None;
311 b.url = "https://blossom.example/DIFFERENT".into();
312
313 let pa = attachment_from_imeta(&attachment_to_imeta(&a), &dir).unwrap();
314 let pb = attachment_from_imeta(&attachment_to_imeta(&b), &dir).unwrap();
315 assert_eq!(pa.nonce, pb.nonce, "precondition: shared nonce");
316 assert_ne!(pa.id, pb.id, "identity must differ per upload");
317 assert_ne!(pa.path, pb.path, "on-disk target must differ per upload");
318 }
319
320 #[test]
321 fn ox_identity_never_claims_downloaded_on_arrival() {
322 let dir = tempfile::tempdir().unwrap();
326 let att = sample("", "png", false);
327 let ox = att.original_hash.clone().unwrap();
328 std::fs::write(dir.path().join(format!("{}.png", ox)), b"some other image").unwrap();
329
330 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), dir.path()).unwrap();
331 assert_eq!(parsed.id, ox, "ox stays the dedup identity");
332 assert!(!parsed.downloaded, "existence of an ox-named file is not proof of download");
333 }
334
335 #[test]
336 fn digest_identity_never_trusts_planted_files() {
337 let dir = tempfile::tempdir().unwrap();
342 let mut att = sample("", "png", false);
343 att.original_hash = None;
344 let digest = crate::crypto::attachment_identity_basis(None, &att.nonce, &att.url);
345 std::fs::write(dir.path().join(format!("{}.png", digest)), b"planted content").unwrap();
346
347 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), dir.path()).unwrap();
348 assert_eq!(parsed.id, digest);
349 assert!(!parsed.downloaded, "a digest-named file is never proof of download");
350 }
351
352 #[test]
353 fn imeta_round_trip_preserves_crypto_and_meta() {
354 let dir = std::env::temp_dir();
355 let att = sample("my report.png", "png", true);
356 let tag = attachment_to_imeta(&att);
357 let back = attachment_from_imeta(&tag, &dir).expect("parses");
358 assert_eq!(back.url, att.url);
359 assert_eq!(back.key, att.key);
360 assert_eq!(back.nonce, att.nonce);
361 assert_eq!(back.size, att.size);
362 assert_eq!(back.original_hash, att.original_hash);
363 assert_eq!(back.name, "my report.png"); assert_eq!(back.extension, "png");
365 assert_eq!(back.group_id, None);
366 let m = back.img_meta.expect("img meta");
367 assert_eq!((m.width, m.height), (800, 600));
368 assert_eq!(m.thumbhash, "TH");
369 }
370
371 #[test]
372 fn spoiler_and_renamed_filenames_survive_imeta() {
373 let dir = std::env::temp_dir();
377 let spoiler = attachment_from_imeta(&attachment_to_imeta(&sample("SPOILER_big reveal.png", "png", true)), &dir).unwrap();
378 assert_eq!(spoiler.name, "SPOILER_big reveal.png");
379 assert!(spoiler.name.to_uppercase().starts_with("SPOILER_"), "spoiler prefix preserved");
380 assert_eq!(spoiler.extension, "png");
381
382 let renamed = attachment_from_imeta(&attachment_to_imeta(&sample("Quarterly Report (final).pdf", "pdf", false)), &dir).unwrap();
383 assert_eq!(renamed.name, "Quarterly Report (final).pdf");
384 assert_eq!(renamed.extension, "pdf");
385 }
386
387 #[test]
388 fn field_key_match_requires_a_following_space_no_prefix_bleed() {
389 let entries = vec!["mime image/png".to_string(), "m image/jpeg".to_string()];
392 assert_eq!(field(&entries, "m"), Some("image/jpeg"));
393 assert_eq!(field(&entries, "mime"), Some("image/png"));
394 assert_eq!(field(&["decryption-key-x abc".to_string()], "decryption-key"), None);
395 assert_eq!(field(&["url".to_string()], "url"), None);
397 }
398
399 #[test]
400 fn multiple_imeta_tags_parse_in_order() {
401 let dir = std::env::temp_dir();
402 let tags = vec![
403 Tag::custom("z", ["pseudonym"]),
404 attachment_to_imeta(&sample("a.png", "png", false)),
405 Tag::custom("ms", ["12"]),
406 attachment_to_imeta(&sample("b.pdf", "pdf", false)),
407 ];
408 let atts = attachments_from_tags(tags.iter(), &dir);
409 assert_eq!(atts.len(), 2);
410 assert_eq!(atts[0].name, "a.png");
411 assert_eq!(atts[1].name, "b.pdf");
412 assert_eq!(atts[1].extension, "pdf");
413 }
414
415 #[test]
416 fn non_imeta_and_incomplete_tags_are_skipped() {
417 let dir = std::env::temp_dir();
418 let not_imeta = Tag::custom("e", ["abc"]);
419 assert!(attachment_from_imeta(¬_imeta, &dir).is_none());
420 let no_url = Tag::custom("imeta", ["m image/png"]);
422 assert!(attachment_from_imeta(&no_url, &dir).is_none());
423 let plain = Tag::custom("imeta", ["url https://x/y"]);
425 assert!(attachment_from_imeta(&plain, &dir).is_some(), "url-only imeta = plaintext attachment");
426 }
427
428 #[test]
429 fn imeta_crypto_params_actually_decrypt_the_ciphertext() {
430 let dir = std::env::temp_dir();
434 let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec();
435 let params = crate::crypto::generate_encryption_params();
436 let ciphertext = crate::crypto::encrypt_data(&plaintext, ¶ms).unwrap();
437
438 let att = Attachment {
439 id: "x".into(),
440 key: params.key.clone(),
441 nonce: params.nonce.clone(),
442 extension: "txt".into(),
443 name: "note.txt".into(),
444 url: "https://blossom.example/blob".into(),
445 path: String::new(),
446 size: ciphertext.len() as u64,
447 img_meta: None,
448 downloading: false,
449 downloaded: false,
450 webxdc_topic: None,
451 group_id: None,
452 original_hash: Some("c".repeat(64)),
453 fallback_urls: Vec::new(),
454 };
455 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses");
456 let decrypted = crate::crypto::decrypt_data(&ciphertext, &parsed.key, &parsed.nonce)
458 .expect("decrypts with imeta-carried params");
459 assert_eq!(decrypted, plaintext, "round-trip plaintext matches");
460 }
461
462 #[test]
463 fn hostile_path_basis_is_rejected() {
464 let dir = std::path::Path::new("/tmp/vector-test-dl");
468 let traversal = Tag::custom("imeta", [
469 "url https://x/y",
470 "decryption-key 00",
471 "decryption-nonce 11",
472 "ox ../../../../etc/passwd",
473 ]);
474 assert!(attachment_from_imeta(&traversal, dir).is_none(), "traversal ox rejected");
475
476 let bad_nonce = Tag::custom("imeta", [
478 "url https://x/y",
479 "decryption-key 00",
480 "decryption-nonce ../evil",
481 ]);
482 assert!(attachment_from_imeta(&bad_nonce, dir).is_none(), "traversal nonce rejected");
483
484 let good = Tag::custom("imeta", [
486 "url https://x/y".to_string(),
487 "decryption-key 00".to_string(),
488 "decryption-nonce 11".to_string(),
489 format!("ox {}", "a".repeat(64)),
490 ]);
491 assert!(attachment_from_imeta(&good, dir).is_some(), "hex ox accepted");
492 }
493
494 #[test]
495 fn unencrypted_nip92_imeta_parses_as_plaintext() {
496 let dir = std::env::temp_dir();
497 let x = "b".repeat(64);
501 let tag = Tag::custom("imeta", [
502 "url https://blossom.ditto.pub/abc.png".to_string(),
503 "m image/png".to_string(),
504 "dim 640x480".to_string(),
505 format!("x {x}"),
506 ]);
507 let att = attachment_from_imeta(&tag, &dir).expect("unencrypted imeta parses");
508 assert!(att.key.is_empty() && att.nonce.is_empty(), "plaintext: no keys");
509 assert_eq!(att.url, "https://blossom.ditto.pub/abc.png");
510 assert_eq!(att.id, x, "identity is the NIP-92 `x` content hash");
511 assert_eq!(att.extension, "png");
512
513 let half = Tag::custom("imeta", ["url https://x/y", "decryption-key 00"]);
515 assert!(attachment_from_imeta(&half, &dir).is_none(), "key without nonce dropped");
516 }
517
518 #[test]
519 fn webxdc_topic_round_trips_imeta_and_garbage_is_dropped() {
520 let dir = std::env::temp_dir();
521 let topic = crate::webxdc::mint_topic_id("hash", "sender");
522 let mut att = sample("game.xdc", "xdc", false);
523 att.webxdc_topic = Some(topic.clone());
524 let back = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses");
525 assert_eq!(back.webxdc_topic.as_deref(), Some(topic.as_str()));
526
527 for bad in ["short", &"A".repeat(53), &"a".repeat(52), &format!("{}!", "A".repeat(51))] {
529 let mut att = sample("game.xdc", "xdc", false);
530 att.webxdc_topic = Some(bad.to_string());
531 let back = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses");
532 assert_eq!(back.webxdc_topic, None, "bad topic {:?} must be dropped", bad);
533 }
534 }
535
536 #[test]
537 fn malformed_imeta_does_not_panic_and_drops_gracefully() {
538 let dir = std::env::temp_dir();
539 let junk = Tag::custom("imeta", [
541 "url", "decryption-key", "random noise here",
544 " ",
545 "url https://x/legit", ]);
547 let att = attachment_from_imeta(&junk, &dir).expect("recovers the valid url as plaintext");
549 assert_eq!(att.url, "https://x/legit");
550 assert!(att.key.is_empty() && att.nonce.is_empty());
551
552 let no_url = Tag::custom("imeta", ["m image/png", "random"]);
554 assert!(attachment_from_imeta(&no_url, &dir).is_none());
555
556 let empty = Tag::custom("imeta", Vec::<String>::new());
558 assert!(attachment_from_imeta(&empty, &dir).is_none());
559 }
560}