Skip to main content

vector_core/community/
attachments.rs

1//! Community message attachments (NIP-92 `imeta`).
2//!
3//! Unlike NIP-17 DMs (one media item per event), a Community message event carries its
4//! caption in `content` plus one `imeta` tag per attachment — so a single message can
5//! mix text and N files. Each `imeta` holds the per-file AES-GCM key+nonce (the NIP-17
6//! attachment technique: fresh random key per file), so the Blossom ciphertext is only
7//! decryptable by members who can open the event.
8
9use std::path::Path;
10use nostr_sdk::prelude::*;
11use crate::types::{Attachment, ImageMetadata};
12
13const IMETA: &str = "imeta";
14
15/// Encode an [`Attachment`] as a NIP-92 `imeta` tag with Vector's encryption fields.
16/// Entries are space-delimited `key value` strings (NIP-92 form); a value may contain
17/// spaces (e.g. a filename) since only the first space delimits key from value.
18pub 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    // Mini Apps: the send-time-minted realtime topic rides the imeta so every
41    // member joins the same gossip topic (see `crate::webxdc::mint_topic_id`).
42    if let Some(topic) = att.webxdc_topic.as_deref().filter(|t| !t.is_empty()) {
43        fields.push(format!("webxdc-topic {}", topic));
44    }
45    // Mirrors of the same ciphertext (NIP-92 `fallback` convention).
46    for fb in &att.fallback_urls {
47        fields.push(format!("fallback {}", fb));
48    }
49    Tag::custom(IMETA, fields)
50}
51
52/// Read a single `key value` field from an `imeta` tag's entries (value is everything
53/// after the first space, so spaces in the value are preserved).
54fn 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
61/// Every value of a repeatable `key value` field, in tag order.
62fn 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
70/// Parse a single `imeta` tag into an [`Attachment`]. `None` if the tag isn't an `imeta`
71/// or is missing the required url / decryption fields. `download_dir` computes the
72/// (not-yet-downloaded) local target path, mirroring the DM file-attachment path.
73pub 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    // Foreign NIP-92 media is UNENCRYPTED — the decryption params are Vector's own
85    // extension and simply absent. Empty key+nonce marks a plaintext attachment;
86    // the download path then skips AES-GCM and saves the bytes verbatim.
87    let key = field(body, "decryption-key").unwrap_or("").to_string();
88    let nonce = field(body, "decryption-nonce").unwrap_or("").to_string();
89    // Half-specified encryption (exactly one of the pair present) is malformed.
90    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    // Prefer the filename's extension (accurate for .toml/.rs/etc. that MIME maps to
98    // octet-stream); fall back to the MIME-derived extension.
99    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    // Vector stamps the plaintext sha256 as `ox`; NIP-92 uses `x`. Either serves
108    // as the dedup / identity basis (content-addressed, so best for foreign media).
109    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    // An ENCRYPTED nonce is author-controlled, feeds the identity digest, and must
125    // be hex for decryption — reject garbage. A plaintext attachment has no nonce;
126    // its identity falls back to the content hash (`ox`/`x`) or `sha256(url)`.
127    if encrypted && (nonce.len() > 128 || !nonce.bytes().all(|b| b.is_ascii_hexdigit())) {
128        return None;
129    }
130
131    // Identity + local path via the shared basis rules (ox for dedup when
132    // present, else a nonce+url digest — see `attachment_identity_basis`).
133    // The ox basis is author-controlled, so require bounded hex before
134    // joining it into a filesystem path — a hostile member can't smuggle
135    // `../` traversal into the persisted `path` (defense-in-depth:
136    // `open_attachment` also re-checks the path is inside the download dir).
137    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    // Arrival never claims downloaded: an ox-named file proves nothing about
143    // content (the download path re-verifies by hash before reuse), and the
144    // honest pipeline never writes digest-named files at all — a file found
145    // under one could only be a foreign plant.
146    let downloaded = false;
147
148    // Bounded sanity on the author-controlled topic: base32 alphabet only, 32-byte
149    // payload (52 chars). Anything else is dropped, not propagated to the realtime layer.
150    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    // `fallback` mirrors: same ciphertext on other hosts. Author-controlled —
155    // https-only, deduped against the primary and each other, capped.
156    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, // Community attachments use explicit key/nonce (NIP-17 technique).
185        original_hash,
186        fallback_urls,
187    })
188}
189
190/// Parse every `imeta` tag on an event into attachments, order preserved.
191/// Capped: a max-size event can carry ~1700 imeta tags, each becoming a
192/// persisted + in-STATE Attachment — bound the per-message amplification.
193pub 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
203/// Strip attachment blob URLs that some clients (e.g. Armada) inline into the
204/// message content IN ADDITION to the `imeta` tag. Vector renders the file from
205/// the imeta, so the inline copy is pure redundancy: it wastes storage and makes
206/// the frontend try to web-preview a raw (often encrypted) blob URL — which can't
207/// decode, so it just errors. Display-only: the wire event and message id are
208/// untouched (we never re-sign), so this can't affect dedup or authority.
209pub 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(); // nothing matched — leave it byte-identical
221    }
222    // Removing a URL that sat on its own line leaves dangling whitespace / a
223    // trailing blank line — tidy per-line trailing space and trim the ends,
224    // keeping the surviving caption's own newlines.
225    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),  // 32-byte key
241            nonce: "1".repeat(32), // 16-byte (0xChat-compatible) nonce
242            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        // Author-controlled entries: primary dups, non-https schemes and
272        // repeated mirrors are dropped, not propagated.
273        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); // url = https://blossom.example/abc
288        // Trailing URL on its own line (the Armada shape) → caption survives clean.
289        assert_eq!(
290            strip_attachment_urls("Check this out\nhttps://blossom.example/abc", &[att.clone()]),
291            "Check this out"
292        );
293        // Content that is ONLY the URL collapses to empty.
294        assert_eq!(strip_attachment_urls("https://blossom.example/abc", &[att.clone()]), "");
295        // A caption that doesn't contain the URL is returned byte-identical.
296        assert_eq!(strip_attachment_urls("just a caption", &[att.clone()]), "just a caption");
297        // No attachments → untouched.
298        assert_eq!(strip_attachment_urls("hello", &[]), "hello");
299    }
300
301    #[test]
302    fn nonce_reuse_yields_distinct_identities() {
303        // Two DIFFERENT uploads sharing a (reused) nonce and lacking ox must
304        // not share an identity or an on-disk path — that cross-binding is
305        // exactly how a new image rendered as an older one.
306        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        // A file existing at {ox}.{ext} proves nothing about content (ox is
323        // the sender's CLAIM); arrival must not bind to it. The download path
324        // re-verifies by hash before any reuse.
325        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        // The honest pipeline never writes files under the digest name, so a
338        // file found there could only be a foreign plant (e.g. an attachment
339        // saved under an attacker-chosen 64-hex filename). Arrival must not
340        // bind to it.
341        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"); // space in filename survives
364        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        // Spoiler is detected receiver-side by a `SPOILER_` prefix on the attachment NAME,
374        // so the name (incl. that prefix, and spaces) must round-trip through imeta intact —
375        // this is what gives Community attachments spoiler/rename parity with DMs.
376        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        // `field(_, "m")` must NOT match a longer key like "mime ..." (shared prefix). The
390        // "key + ' '" requirement guards this; lock it so future imeta fields can't collide.
391        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        // A key present with no value (no following space) yields None, not a panic.
396        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(&not_imeta, &dir).is_none());
420        // No `url` at all → None (NIP-92 requires a url).
421        let no_url = Tag::custom("imeta", ["m image/png"]);
422        assert!(attachment_from_imeta(&no_url, &dir).is_none());
423        // A url-only imeta is valid now — an unencrypted (plaintext) attachment.
424        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        // End-to-end attachment crypto: encrypt a plaintext with the real params, carry the
431        // key/nonce via imeta, parse them back out, and confirm they decrypt the ciphertext.
432        // This is the receiver's download path in miniature (minus the Blossom fetch).
433        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, &params).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        // The parsed key/nonce (straight off the imeta) must decrypt the ciphertext.
457        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        // A channel member authors the imeta, so the path basis (`ox`, else `nonce`) is
465        // attacker-controlled. A non-hex / traversal basis must be refused, never joined
466        // into a filesystem path.
467        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        // Falls back to nonce when ox absent — a non-hex nonce is likewise rejected.
477        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        // A legitimate hex basis still parses.
485        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        // A foreign client's plain NIP-92 imeta: url + m + dim + `x` (sha256), and
498        // NO decryption params. It must parse (empty key/nonce = plaintext) so we can
499        // best-effort render it, identity keyed by the `x` content hash.
500        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        // Half-specified encryption (key without a nonce) is still refused.
514        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        // Author-controlled: wrong-length / off-alphabet topics are dropped, not propagated.
528        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        // Garbage entries, duplicate keys, value-less keys, weird spacing — must not panic.
540        let junk = Tag::custom("imeta", [
541            "url",                 // no value (skipped: `field` needs `key<space>`)
542            "decryption-key",      // no value
543            "random noise here",
544            "  ",
545            "url https://x/legit", // a later valid url
546        ]);
547        // The valid url is recovered (no decryption fields → plaintext); no panic.
548        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        // No url anywhere → None (not a panic).
553        let no_url = Tag::custom("imeta", ["m image/png", "random"]);
554        assert!(attachment_from_imeta(&no_url, &dir).is_none());
555
556        // Empty imeta (just the tag name) → None.
557        let empty = Tag::custom("imeta", Vec::<String>::new());
558        assert!(attachment_from_imeta(&empty, &dir).is_none());
559    }
560}