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 Tag::custom(TagKind::Custom(IMETA.into()), fields)
46}
47
48fn field<'a>(entries: &'a [String], key: &str) -> Option<&'a str> {
51 entries.iter().find_map(|e| {
52 e.strip_prefix(key)
53 .and_then(|rest| rest.strip_prefix(' '))
54 })
55}
56
57pub fn attachment_from_imeta(tag: &Tag, download_dir: &Path) -> Option<Attachment> {
61 let entries = tag.as_slice();
62 if entries.first().map(String::as_str) != Some(IMETA) {
63 return None;
64 }
65 let body = &entries[1..];
66
67 let url = field(body, "url")?.to_string();
68 if url.is_empty() {
69 return None;
70 }
71 let key = field(body, "decryption-key").unwrap_or("").to_string();
75 let nonce = field(body, "decryption-nonce").unwrap_or("").to_string();
76 if key.is_empty() != nonce.is_empty() {
78 return None;
79 }
80 let encrypted = !key.is_empty();
81
82 let mime = field(body, "m").unwrap_or("application/octet-stream");
83 let name = field(body, "name").map(crate::crypto::sanitize_filename).unwrap_or_default();
84 let extension = name
87 .rsplit('.')
88 .next()
89 .filter(|e| !e.is_empty() && *e != name)
90 .map(|e| e.to_lowercase())
91 .unwrap_or_else(|| crate::crypto::extension_from_mime(mime));
92
93 let size = field(body, "size").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
94 let original_hash = field(body, "ox").or_else(|| field(body, "x"))
97 .map(|s| s.to_string()).filter(|s| !s.is_empty());
98
99 let img_meta = {
100 let thumb = field(body, "thumb").map(|s| s.to_string());
101 let dim = field(body, "dim").and_then(|s| {
102 let (w, h) = s.split_once('x')?;
103 Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?))
104 });
105 match (thumb, dim) {
106 (Some(thumbhash), Some((width, height))) => Some(ImageMetadata { thumbhash, width, height }),
107 _ => None,
108 }
109 };
110
111 if encrypted && (nonce.len() > 128 || !nonce.bytes().all(|b| b.is_ascii_hexdigit())) {
115 return None;
116 }
117
118 let basis = crate::crypto::attachment_identity_basis(original_hash.as_deref(), &nonce, &url);
125 if basis.is_empty() || basis.len() > 128 || !basis.bytes().all(|b| b.is_ascii_hexdigit()) {
126 return None;
127 }
128 let path = download_dir.join(format!("{}.{}", basis, extension));
129 let downloaded = false;
134
135 let webxdc_topic = field(body, "webxdc-topic")
138 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
139 .map(|t| t.to_string());
140
141 Some(Attachment {
142 id: basis,
143 key,
144 nonce,
145 extension,
146 name,
147 url,
148 path: path.to_string_lossy().to_string(),
149 size,
150 img_meta,
151 downloading: false,
152 downloaded,
153 webxdc_topic,
154 group_id: None, original_hash,
156 scheme_version: None,
157 mls_filename: None,
158 })
159}
160
161pub fn attachments_from_tags<'a>(
165 tags: impl Iterator<Item = &'a Tag>,
166 download_dir: &Path,
167) -> Vec<Attachment> {
168 const MAX_ATTACHMENTS_PER_MESSAGE: usize = 32;
169 tags.filter_map(|t| attachment_from_imeta(t, download_dir))
170 .take(MAX_ATTACHMENTS_PER_MESSAGE)
171 .collect()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn sample(name: &str, ext: &str, with_img: bool) -> Attachment {
179 Attachment {
180 id: "h".into(),
181 key: "0".repeat(64), nonce: "1".repeat(32), extension: ext.into(),
184 name: name.into(),
185 url: "https://blossom.example/abc".into(),
186 path: String::new(),
187 size: 4096,
188 img_meta: with_img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 800, height: 600 }),
189 downloading: false,
190 downloaded: false,
191 webxdc_topic: None,
192 group_id: None,
193 original_hash: Some("a".repeat(64)),
194 scheme_version: None,
195 mls_filename: None,
196 }
197 }
198
199 #[test]
200 fn nonce_reuse_yields_distinct_identities() {
201 let dir = std::env::temp_dir();
205 let mut a = sample("", "png", false);
206 a.original_hash = None;
207 let mut b = sample("", "png", false);
208 b.original_hash = None;
209 b.url = "https://blossom.example/DIFFERENT".into();
210
211 let pa = attachment_from_imeta(&attachment_to_imeta(&a), &dir).unwrap();
212 let pb = attachment_from_imeta(&attachment_to_imeta(&b), &dir).unwrap();
213 assert_eq!(pa.nonce, pb.nonce, "precondition: shared nonce");
214 assert_ne!(pa.id, pb.id, "identity must differ per upload");
215 assert_ne!(pa.path, pb.path, "on-disk target must differ per upload");
216 }
217
218 #[test]
219 fn ox_identity_never_claims_downloaded_on_arrival() {
220 let dir = tempfile::tempdir().unwrap();
224 let att = sample("", "png", false);
225 let ox = att.original_hash.clone().unwrap();
226 std::fs::write(dir.path().join(format!("{}.png", ox)), b"some other image").unwrap();
227
228 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), dir.path()).unwrap();
229 assert_eq!(parsed.id, ox, "ox stays the dedup identity");
230 assert!(!parsed.downloaded, "existence of an ox-named file is not proof of download");
231 }
232
233 #[test]
234 fn digest_identity_never_trusts_planted_files() {
235 let dir = tempfile::tempdir().unwrap();
240 let mut att = sample("", "png", false);
241 att.original_hash = None;
242 let digest = crate::crypto::attachment_identity_basis(None, &att.nonce, &att.url);
243 std::fs::write(dir.path().join(format!("{}.png", digest)), b"planted content").unwrap();
244
245 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), dir.path()).unwrap();
246 assert_eq!(parsed.id, digest);
247 assert!(!parsed.downloaded, "a digest-named file is never proof of download");
248 }
249
250 #[test]
251 fn imeta_round_trip_preserves_crypto_and_meta() {
252 let dir = std::env::temp_dir();
253 let att = sample("my report.png", "png", true);
254 let tag = attachment_to_imeta(&att);
255 let back = attachment_from_imeta(&tag, &dir).expect("parses");
256 assert_eq!(back.url, att.url);
257 assert_eq!(back.key, att.key);
258 assert_eq!(back.nonce, att.nonce);
259 assert_eq!(back.size, att.size);
260 assert_eq!(back.original_hash, att.original_hash);
261 assert_eq!(back.name, "my report.png"); assert_eq!(back.extension, "png");
263 assert_eq!(back.group_id, None);
264 let m = back.img_meta.expect("img meta");
265 assert_eq!((m.width, m.height), (800, 600));
266 assert_eq!(m.thumbhash, "TH");
267 }
268
269 #[test]
270 fn spoiler_and_renamed_filenames_survive_imeta() {
271 let dir = std::env::temp_dir();
275 let spoiler = attachment_from_imeta(&attachment_to_imeta(&sample("SPOILER_big reveal.png", "png", true)), &dir).unwrap();
276 assert_eq!(spoiler.name, "SPOILER_big reveal.png");
277 assert!(spoiler.name.to_uppercase().starts_with("SPOILER_"), "spoiler prefix preserved");
278 assert_eq!(spoiler.extension, "png");
279
280 let renamed = attachment_from_imeta(&attachment_to_imeta(&sample("Quarterly Report (final).pdf", "pdf", false)), &dir).unwrap();
281 assert_eq!(renamed.name, "Quarterly Report (final).pdf");
282 assert_eq!(renamed.extension, "pdf");
283 }
284
285 #[test]
286 fn field_key_match_requires_a_following_space_no_prefix_bleed() {
287 let entries = vec!["mime image/png".to_string(), "m image/jpeg".to_string()];
290 assert_eq!(field(&entries, "m"), Some("image/jpeg"));
291 assert_eq!(field(&entries, "mime"), Some("image/png"));
292 assert_eq!(field(&["decryption-key-x abc".to_string()], "decryption-key"), None);
293 assert_eq!(field(&["url".to_string()], "url"), None);
295 }
296
297 #[test]
298 fn multiple_imeta_tags_parse_in_order() {
299 let dir = std::env::temp_dir();
300 let tags = vec![
301 Tag::custom(TagKind::Custom("z".into()), ["pseudonym"]),
302 attachment_to_imeta(&sample("a.png", "png", false)),
303 Tag::custom(TagKind::Custom("ms".into()), ["12"]),
304 attachment_to_imeta(&sample("b.pdf", "pdf", false)),
305 ];
306 let atts = attachments_from_tags(tags.iter(), &dir);
307 assert_eq!(atts.len(), 2);
308 assert_eq!(atts[0].name, "a.png");
309 assert_eq!(atts[1].name, "b.pdf");
310 assert_eq!(atts[1].extension, "pdf");
311 }
312
313 #[test]
314 fn non_imeta_and_incomplete_tags_are_skipped() {
315 let dir = std::env::temp_dir();
316 let not_imeta = Tag::custom(TagKind::Custom("e".into()), ["abc"]);
317 assert!(attachment_from_imeta(¬_imeta, &dir).is_none());
318 let no_url = Tag::custom(TagKind::Custom("imeta".into()), ["m image/png"]);
320 assert!(attachment_from_imeta(&no_url, &dir).is_none());
321 let plain = Tag::custom(TagKind::Custom("imeta".into()), ["url https://x/y"]);
323 assert!(attachment_from_imeta(&plain, &dir).is_some(), "url-only imeta = plaintext attachment");
324 }
325
326 #[test]
327 fn imeta_crypto_params_actually_decrypt_the_ciphertext() {
328 let dir = std::env::temp_dir();
332 let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec();
333 let params = crate::crypto::generate_encryption_params();
334 let ciphertext = crate::crypto::encrypt_data(&plaintext, ¶ms).unwrap();
335
336 let att = Attachment {
337 id: "x".into(),
338 key: params.key.clone(),
339 nonce: params.nonce.clone(),
340 extension: "txt".into(),
341 name: "note.txt".into(),
342 url: "https://blossom.example/blob".into(),
343 path: String::new(),
344 size: ciphertext.len() as u64,
345 img_meta: None,
346 downloading: false,
347 downloaded: false,
348 webxdc_topic: None,
349 group_id: None,
350 original_hash: Some("c".repeat(64)),
351 scheme_version: None,
352 mls_filename: None,
353 };
354 let parsed = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses");
355 let decrypted = crate::crypto::decrypt_data(&ciphertext, &parsed.key, &parsed.nonce)
357 .expect("decrypts with imeta-carried params");
358 assert_eq!(decrypted, plaintext, "round-trip plaintext matches");
359 }
360
361 #[test]
362 fn hostile_path_basis_is_rejected() {
363 let dir = std::path::Path::new("/tmp/vector-test-dl");
367 let traversal = Tag::custom(TagKind::Custom("imeta".into()), [
368 "url https://x/y",
369 "decryption-key 00",
370 "decryption-nonce 11",
371 "ox ../../../../etc/passwd",
372 ]);
373 assert!(attachment_from_imeta(&traversal, dir).is_none(), "traversal ox rejected");
374
375 let bad_nonce = Tag::custom(TagKind::Custom("imeta".into()), [
377 "url https://x/y",
378 "decryption-key 00",
379 "decryption-nonce ../evil",
380 ]);
381 assert!(attachment_from_imeta(&bad_nonce, dir).is_none(), "traversal nonce rejected");
382
383 let good = Tag::custom(TagKind::Custom("imeta".into()), [
385 "url https://x/y".to_string(),
386 "decryption-key 00".to_string(),
387 "decryption-nonce 11".to_string(),
388 format!("ox {}", "a".repeat(64)),
389 ]);
390 assert!(attachment_from_imeta(&good, dir).is_some(), "hex ox accepted");
391 }
392
393 #[test]
394 fn unencrypted_nip92_imeta_parses_as_plaintext() {
395 let dir = std::env::temp_dir();
396 let x = "b".repeat(64);
400 let tag = Tag::custom(TagKind::Custom("imeta".into()), [
401 "url https://blossom.ditto.pub/abc.png".to_string(),
402 "m image/png".to_string(),
403 "dim 640x480".to_string(),
404 format!("x {x}"),
405 ]);
406 let att = attachment_from_imeta(&tag, &dir).expect("unencrypted imeta parses");
407 assert!(att.key.is_empty() && att.nonce.is_empty(), "plaintext: no keys");
408 assert_eq!(att.url, "https://blossom.ditto.pub/abc.png");
409 assert_eq!(att.id, x, "identity is the NIP-92 `x` content hash");
410 assert_eq!(att.extension, "png");
411
412 let half = Tag::custom(TagKind::Custom("imeta".into()), ["url https://x/y", "decryption-key 00"]);
414 assert!(attachment_from_imeta(&half, &dir).is_none(), "key without nonce dropped");
415 }
416
417 #[test]
418 fn webxdc_topic_round_trips_imeta_and_garbage_is_dropped() {
419 let dir = std::env::temp_dir();
420 let topic = crate::webxdc::mint_topic_id("hash", "sender");
421 let mut att = sample("game.xdc", "xdc", false);
422 att.webxdc_topic = Some(topic.clone());
423 let back = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses");
424 assert_eq!(back.webxdc_topic.as_deref(), Some(topic.as_str()));
425
426 for bad in ["short", &"A".repeat(53), &"a".repeat(52), &format!("{}!", "A".repeat(51))] {
428 let mut att = sample("game.xdc", "xdc", false);
429 att.webxdc_topic = Some(bad.to_string());
430 let back = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses");
431 assert_eq!(back.webxdc_topic, None, "bad topic {:?} must be dropped", bad);
432 }
433 }
434
435 #[test]
436 fn malformed_imeta_does_not_panic_and_drops_gracefully() {
437 let dir = std::env::temp_dir();
438 let junk = Tag::custom(TagKind::Custom("imeta".into()), [
440 "url", "decryption-key", "random noise here",
443 " ",
444 "url https://x/legit", ]);
446 let att = attachment_from_imeta(&junk, &dir).expect("recovers the valid url as plaintext");
448 assert_eq!(att.url, "https://x/legit");
449 assert!(att.key.is_empty() && att.nonce.is_empty());
450
451 let no_url = Tag::custom(TagKind::Custom("imeta".into()), ["m image/png", "random"]);
453 assert!(attachment_from_imeta(&no_url, &dir).is_none());
454
455 let empty = Tag::custom(TagKind::Custom("imeta".into()), Vec::<String>::new());
457 assert!(attachment_from_imeta(&empty, &dir).is_none());
458 }
459}