Skip to main content

nula_core/nips/
nip94.rs

1//! [NIP-94] File Metadata.
2//!
3//! `kind: 1063` describes a file: where to fetch it, what type it is,
4//! how big it is, what it looks like, and (most importantly) the
5//! SHA-256 over the bytes so consumers can verify integrity. The
6//! `.content` is the human-readable caption; every interesting fact
7//! is a tag.
8//!
9//! # Spec coverage
10//!
11//! Upstream `rust-nostr` implements only the four fields it cares
12//! about (`url`, `m`, `x`, `dim`, `size`, `magnet`, `blurhash`) and
13//! ignores everything else NIP-94 mentions. We model the **complete**
14//! set so a NIP-94 event round-trips byte-for-byte:
15//!
16//! | Tag         | Field                                 | Type            |
17//! |-------------|---------------------------------------|-----------------|
18//! | `url`       | [`FileMetadata::url`]                 | [`Url`]         |
19//! | `m`         | [`FileMetadata::mime_type`]           | `String`        |
20//! | `x`         | [`FileMetadata::hash`]                | `[u8; 32]`      |
21//! | `ox`        | [`FileMetadata::original_hash`]       | `[u8; 32]`      |
22//! | `size`      | [`FileMetadata::size`]                | `u64`           |
23//! | `dim`       | [`FileMetadata::dim`]                 | [`ImageDimensions`] |
24//! | `magnet`    | [`FileMetadata::magnet`]              | `String`        |
25//! | `i`         | [`FileMetadata::torrent_infohash`]    | `String`        |
26//! | `blurhash`  | [`FileMetadata::blurhash`]            | `String`        |
27//! | `thumb`     | [`FileMetadata::thumb`]               | [`FileVariant`] |
28//! | `image`     | [`FileMetadata::preview_image`]       | [`FileVariant`] |
29//! | `summary`   | [`FileMetadata::summary`]             | `String`        |
30//! | `alt`       | [`FileMetadata::alt`]                 | `String`        |
31//! | `fallback`  | [`FileMetadata::fallback_urls`]       | `Vec<Url>`      |
32//! | `service`   | [`FileMetadata::service`]             | `String`        |
33//!
34//! # Authoring vs reading
35//!
36//! - Author with [`EventBuilder::file_metadata`]; the builder takes
37//!   a [`FileMetadata`] bundle plus a caption string and emits one
38//!   event with every populated field as a tag.
39//! - Read with [`FileMetadata::from_event`], which refuses non-1063
40//!   kinds and reports specific errors for the three required tags
41//!   (`url` / `m` / `x`). Optional tags that are present but
42//!   malformed (bad hex, bad URL, bad dimensions) flow through as
43//!   typed errors rather than being silently dropped — this is a
44//!   deliberate departure from the "best effort" upstream policy.
45//!
46//! # Hash representation
47//!
48//! Hashes are stored as raw `[u8; 32]` so callers can compare them
49//! cheaply against [`crate::EventId`] / `Sha256` digests without an
50//! intermediate hex parse. The wire format remains lowercase 64-char
51//! hex per NIP-94 §"x".
52//!
53//! [NIP-94]: https://github.com/nostr-protocol/nips/blob/master/94.md
54
55use thiserror::Error;
56
57use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
58use crate::types::{ImageDimensions, ImageError, Url, UrlError};
59use crate::util::hex::{self, HexError};
60
61/// `kind: 1063` — file metadata.
62pub const KIND_FILE_METADATA: Kind = Kind::FILE_METADATA;
63
64/// `url` tag wire head.
65pub const URL_TAG: &str = "url";
66/// `ox` tag wire head (original SHA-256).
67pub const OX_TAG: &str = "ox";
68/// `size` tag wire head.
69pub const SIZE_TAG: &str = "size";
70/// `dim` tag wire head.
71pub const DIM_TAG: &str = "dim";
72/// `magnet` tag wire head.
73pub const MAGNET_TAG: &str = "magnet";
74/// `blurhash` tag wire head.
75pub const BLURHASH_TAG: &str = "blurhash";
76/// `thumb` tag wire head.
77pub const THUMB_TAG: &str = "thumb";
78/// `image` tag wire head (preview, distinct from `m` / `x`).
79pub const IMAGE_TAG: &str = "image";
80/// `summary` tag wire head.
81pub const SUMMARY_TAG: &str = "summary";
82/// `alt` tag wire head.
83pub const ALT_TAG: &str = "alt";
84/// `fallback` tag wire head.
85pub const FALLBACK_TAG: &str = "fallback";
86/// `service` tag wire head.
87pub const SERVICE_TAG: &str = "service";
88
89/// One image / thumbnail variant. Both `thumb` and `image` tags share
90/// this shape: a URL, optionally followed by a SHA-256 hash that
91/// pins the variant's bytes.
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93pub struct FileVariant {
94    /// Variant URL.
95    pub url: Url,
96    /// Optional SHA-256 over the variant's bytes (raw bytes, NOT
97    /// hex). `None` matches the spec's two-column form
98    /// `["thumb", "<url>"]`.
99    pub hash: Option<[u8; 32]>,
100}
101
102impl FileVariant {
103    /// Construct a variant with no integrity hash.
104    #[must_use]
105    pub const fn new(url: Url) -> Self {
106        Self { url, hash: None }
107    }
108
109    /// Attach a SHA-256 hash.
110    #[must_use]
111    pub const fn with_hash(mut self, hash: [u8; 32]) -> Self {
112        self.hash = Some(hash);
113        self
114    }
115}
116
117/// Typed bundle of every NIP-94 field.
118///
119/// Build incrementally with [`Self::new`] + the chainable setters,
120/// or hydrate from a wire event with [`Self::from_event`].
121#[derive(Debug, Clone, PartialEq, Eq, Default)]
122pub struct FileMetadata {
123    /// Download URL (`url` tag — required).
124    pub url: Option<Url>,
125    /// MIME type (`m` tag — required).
126    pub mime_type: Option<String>,
127    /// SHA-256 of the served bytes (`x` tag — required).
128    pub hash: Option<[u8; 32]>,
129    /// SHA-256 of the original bytes before any server-side
130    /// transformation (`ox` tag).
131    pub original_hash: Option<[u8; 32]>,
132    /// File size in bytes (`size` tag).
133    pub size: Option<u64>,
134    /// Pixel dimensions (`dim` tag).
135    pub dim: Option<ImageDimensions>,
136    /// Magnet URI (`magnet` tag).
137    pub magnet: Option<String>,
138    /// Torrent infohash (`i` tag).
139    pub torrent_infohash: Option<String>,
140    /// Blurhash placeholder (`blurhash` tag).
141    pub blurhash: Option<String>,
142    /// Thumbnail variant (`thumb` tag).
143    pub thumb: Option<FileVariant>,
144    /// Preview image variant (`image` tag).
145    pub preview_image: Option<FileVariant>,
146    /// Text excerpt (`summary` tag).
147    pub summary: Option<String>,
148    /// Accessibility description (`alt` tag).
149    pub alt: Option<String>,
150    /// Zero or more fallback download URLs (`fallback` tags).
151    pub fallback_urls: Vec<Url>,
152    /// Serving-service identifier such as `nip96` (`service` tag).
153    pub service: Option<String>,
154}
155
156impl FileMetadata {
157    /// Construct with the three required fields.
158    #[must_use]
159    pub fn new(url: Url, mime_type: impl Into<String>, hash: [u8; 32]) -> Self {
160        Self {
161            url: Some(url),
162            mime_type: Some(mime_type.into()),
163            hash: Some(hash),
164            ..Self::default()
165        }
166    }
167
168    /// Set [`Self::original_hash`].
169    #[must_use]
170    pub const fn original_hash(mut self, hash: [u8; 32]) -> Self {
171        self.original_hash = Some(hash);
172        self
173    }
174
175    /// Set [`Self::size`].
176    #[must_use]
177    pub const fn size(mut self, size: u64) -> Self {
178        self.size = Some(size);
179        self
180    }
181
182    /// Set [`Self::dim`].
183    #[must_use]
184    pub const fn dim(mut self, dim: ImageDimensions) -> Self {
185        self.dim = Some(dim);
186        self
187    }
188
189    /// Set [`Self::magnet`].
190    #[must_use]
191    pub fn magnet(mut self, magnet: impl Into<String>) -> Self {
192        self.magnet = Some(magnet.into());
193        self
194    }
195
196    /// Set [`Self::torrent_infohash`].
197    #[must_use]
198    pub fn torrent_infohash(mut self, infohash: impl Into<String>) -> Self {
199        self.torrent_infohash = Some(infohash.into());
200        self
201    }
202
203    /// Set [`Self::blurhash`].
204    #[must_use]
205    pub fn blurhash(mut self, blurhash: impl Into<String>) -> Self {
206        self.blurhash = Some(blurhash.into());
207        self
208    }
209
210    /// Set [`Self::thumb`].
211    #[must_use]
212    pub fn thumb(mut self, thumb: FileVariant) -> Self {
213        self.thumb = Some(thumb);
214        self
215    }
216
217    /// Set [`Self::preview_image`].
218    #[must_use]
219    pub fn preview_image(mut self, image: FileVariant) -> Self {
220        self.preview_image = Some(image);
221        self
222    }
223
224    /// Set [`Self::summary`].
225    #[must_use]
226    pub fn summary(mut self, summary: impl Into<String>) -> Self {
227        self.summary = Some(summary.into());
228        self
229    }
230
231    /// Set [`Self::alt`].
232    #[must_use]
233    pub fn alt(mut self, alt: impl Into<String>) -> Self {
234        self.alt = Some(alt.into());
235        self
236    }
237
238    /// Append one fallback URL.
239    #[must_use]
240    pub fn fallback(mut self, url: Url) -> Self {
241        self.fallback_urls.push(url);
242        self
243    }
244
245    /// Set [`Self::service`].
246    #[must_use]
247    pub fn service(mut self, service: impl Into<String>) -> Self {
248        self.service = Some(service.into());
249        self
250    }
251
252    /// Parse a `kind: 1063` event back into a typed bundle.
253    ///
254    /// # Errors
255    ///
256    /// - [`FileMetadataError::WrongKind`] for any other kind.
257    /// - [`FileMetadataError::MissingUrl`] / `MissingMimeType` /
258    ///   `MissingHash` when the corresponding required tag is absent.
259    /// - [`FileMetadataError::InvalidUrl`] / `InvalidHash` /
260    ///   `InvalidSize` / `InvalidDim` when the value is present but
261    ///   malformed.
262    pub fn from_event(event: &Event) -> Result<Self, FileMetadataError> {
263        if event.kind != KIND_FILE_METADATA {
264            return Err(FileMetadataError::WrongKind(event.kind));
265        }
266        Self::from_tags(&event.tags)
267    }
268
269    /// Parse a tag list (without requiring a wrapping event). Useful
270    /// when callers already know the event kind matches and only
271    /// want the metadata reconstruction.
272    ///
273    /// # Errors
274    ///
275    /// See [`Self::from_event`].
276    pub fn from_tags(tags: &Tags) -> Result<Self, FileMetadataError> {
277        let url_str = custom_value(tags, URL_TAG).ok_or(FileMetadataError::MissingUrl)?;
278        let url = Url::parse(url_str).map_err(FileMetadataError::InvalidUrl)?;
279
280        let mime_type = single_value(tags, Alphabet::M)
281            .ok_or(FileMetadataError::MissingMimeType)?
282            .to_owned();
283        let hash_hex = single_value(tags, Alphabet::X).ok_or(FileMetadataError::MissingHash)?;
284        let hash = parse_sha256(hash_hex)?;
285
286        let mut metadata = Self::new(url, mime_type, hash);
287
288        if let Some(raw) = custom_value(tags, OX_TAG) {
289            metadata.original_hash = Some(parse_sha256(raw)?);
290        }
291        if let Some(raw) = custom_value(tags, SIZE_TAG) {
292            metadata.size = Some(
293                raw.parse::<u64>()
294                    .map_err(|_| FileMetadataError::InvalidSize(raw.to_owned()))?,
295            );
296        }
297        if let Some(raw) = custom_value(tags, DIM_TAG) {
298            metadata.dim = Some(
299                raw.parse::<ImageDimensions>()
300                    .map_err(FileMetadataError::InvalidDim)?,
301            );
302        }
303        if let Some(raw) = custom_value(tags, MAGNET_TAG) {
304            metadata.magnet = Some(raw.to_owned());
305        }
306        if let Some(raw) = single_value(tags, Alphabet::I) {
307            metadata.torrent_infohash = Some(raw.to_owned());
308        }
309        if let Some(raw) = custom_value(tags, BLURHASH_TAG) {
310            metadata.blurhash = Some(raw.to_owned());
311        }
312        metadata.thumb = parse_variant(tags, THUMB_TAG)?;
313        metadata.preview_image = parse_variant(tags, IMAGE_TAG)?;
314        if let Some(raw) = custom_value(tags, SUMMARY_TAG) {
315            metadata.summary = Some(raw.to_owned());
316        }
317        if let Some(raw) = custom_value(tags, ALT_TAG) {
318            metadata.alt = Some(raw.to_owned());
319        }
320        for tag in tags.find_all(&TagKind::Custom(FALLBACK_TAG.to_owned())) {
321            if let Some(raw) = tag.get(1) {
322                let parsed = Url::parse(raw).map_err(FileMetadataError::InvalidUrl)?;
323                metadata.fallback_urls.push(parsed);
324            }
325        }
326        if let Some(raw) = custom_value(tags, SERVICE_TAG) {
327            metadata.service = Some(raw.to_owned());
328        }
329        Ok(metadata)
330    }
331
332    /// Materialise the bundle into a `Vec<Tag>` ready for use with
333    /// [`EventBuilder::tag`] / [`EventBuilder::tags`]. Required
334    /// fields must be set; missing required fields produce an
335    /// `Err(FileMetadataError::Missing*)` so authoring stays
336    /// symmetric with reading.
337    ///
338    /// # Errors
339    ///
340    /// Returns the corresponding `Missing*` error when a required
341    /// field is unset.
342    pub fn to_tags(&self) -> Result<Vec<Tag>, FileMetadataError> {
343        let url = self.url.as_ref().ok_or(FileMetadataError::MissingUrl)?;
344        let mime = self
345            .mime_type
346            .as_deref()
347            .ok_or(FileMetadataError::MissingMimeType)?;
348        let hash = self.hash.ok_or(FileMetadataError::MissingHash)?;
349
350        let mut tags: Vec<Tag> = Vec::with_capacity(3 + self.fallback_urls.len());
351        tags.push(custom(URL_TAG, [url.as_str().to_owned()]));
352        tags.push(letter(Alphabet::M, [mime.to_owned()]));
353        tags.push(letter(Alphabet::X, [hex::encode(hash)]));
354
355        if let Some(ox) = self.original_hash {
356            tags.push(custom(OX_TAG, [hex::encode(ox)]));
357        }
358        if let Some(size) = self.size {
359            tags.push(custom(SIZE_TAG, [size.to_string()]));
360        }
361        if let Some(dim) = self.dim {
362            tags.push(custom(DIM_TAG, [dim.to_string()]));
363        }
364        if let Some(magnet) = &self.magnet {
365            tags.push(custom(MAGNET_TAG, [magnet.clone()]));
366        }
367        if let Some(infohash) = &self.torrent_infohash {
368            tags.push(letter(Alphabet::I, [infohash.clone()]));
369        }
370        if let Some(blurhash) = &self.blurhash {
371            tags.push(custom(BLURHASH_TAG, [blurhash.clone()]));
372        }
373        if let Some(thumb) = &self.thumb {
374            tags.push(variant_tag(THUMB_TAG, thumb));
375        }
376        if let Some(preview) = &self.preview_image {
377            tags.push(variant_tag(IMAGE_TAG, preview));
378        }
379        if let Some(summary) = &self.summary {
380            tags.push(custom(SUMMARY_TAG, [summary.clone()]));
381        }
382        if let Some(alt) = &self.alt {
383            tags.push(custom(ALT_TAG, [alt.clone()]));
384        }
385        for fallback in &self.fallback_urls {
386            tags.push(custom(FALLBACK_TAG, [fallback.as_str().to_owned()]));
387        }
388        if let Some(service) = &self.service {
389            tags.push(custom(SERVICE_TAG, [service.clone()]));
390        }
391        Ok(tags)
392    }
393}
394
395/// Errors raised by [`FileMetadata::from_event`] / [`FileMetadata::to_tags`].
396#[derive(Debug, Error)]
397#[non_exhaustive]
398pub enum FileMetadataError {
399    /// The event was not `kind: 1063`.
400    #[error("expected kind 1063 (file metadata), got kind {}", .0.as_u16())]
401    WrongKind(Kind),
402    /// The required `url` tag is missing.
403    #[error("NIP-94 event must carry a `url` tag")]
404    MissingUrl,
405    /// The required `m` (MIME) tag is missing.
406    #[error("NIP-94 event must carry an `m` (MIME) tag")]
407    MissingMimeType,
408    /// The required `x` (SHA-256) tag is missing.
409    #[error("NIP-94 event must carry an `x` (SHA-256) tag")]
410    MissingHash,
411    /// A URL value did not parse.
412    #[error("invalid URL: {0}")]
413    InvalidUrl(#[source] UrlError),
414    /// A SHA-256 value did not decode as 32 bytes of hex.
415    #[error("invalid SHA-256 hex: {0}")]
416    InvalidHash(#[source] HexError),
417    /// SHA-256 hex was the wrong length.
418    #[error("SHA-256 must be 64 hex chars, got {0}")]
419    InvalidHashLength(usize),
420    /// The `size` value did not parse as `u64`.
421    #[error("invalid `size` value `{0}`: must be unsigned integer bytes")]
422    InvalidSize(String),
423    /// The `dim` value did not parse as `<width>x<height>`.
424    #[error("invalid `dim` value: {0}")]
425    InvalidDim(#[source] ImageError),
426}
427
428fn parse_sha256(input: &str) -> Result<[u8; 32], FileMetadataError> {
429    if input.len() != 64 {
430        return Err(FileMetadataError::InvalidHashLength(input.len()));
431    }
432    let mut bytes = [0_u8; 32];
433    hex::decode_to_slice(input, &mut bytes).map_err(FileMetadataError::InvalidHash)?;
434    Ok(bytes)
435}
436
437fn parse_variant(tags: &Tags, name: &str) -> Result<Option<FileVariant>, FileMetadataError> {
438    let head = TagKind::Custom(name.to_owned());
439    let Some(tag) = tags.find_first(&head) else {
440        return Ok(None);
441    };
442    let Some(url_str) = tag.get(1) else {
443        return Ok(None);
444    };
445    let url = Url::parse(url_str).map_err(FileMetadataError::InvalidUrl)?;
446    let hash = match tag.get(2) {
447        Some(hex) if !hex.is_empty() => Some(parse_sha256(hex)?),
448        _ => None,
449    };
450    Ok(Some(FileVariant { url, hash }))
451}
452
453fn variant_tag(name: &str, variant: &FileVariant) -> Tag {
454    let mut values: Vec<String> = Vec::with_capacity(2);
455    values.push(variant.url.as_str().to_owned());
456    if let Some(hash) = variant.hash {
457        values.push(hex::encode(hash));
458    }
459    custom(name, values)
460}
461
462fn single_value(tags: &Tags, letter: Alphabet) -> Option<&str> {
463    let head = TagKind::single_letter(SingleLetterTag::lowercase(letter));
464    tags.find_first(&head).and_then(|tag| tag.get(1))
465}
466
467fn custom_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
468    tags.iter()
469        .find(|tag| tag.name() == name)
470        .and_then(|tag| tag.get(1))
471}
472
473fn custom<I, S>(name: &str, args: I) -> Tag
474where
475    I: IntoIterator<Item = S>,
476    S: Into<String>,
477{
478    Tag::with(&TagKind::Custom(name.to_owned()), args)
479}
480
481fn letter<I, S>(alphabet: Alphabet, args: I) -> Tag
482where
483    I: IntoIterator<Item = S>,
484    S: Into<String>,
485{
486    let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
487    Tag::with(&head, args)
488}
489
490impl EventBuilder {
491    /// Author a NIP-94 file-metadata event.
492    ///
493    /// `caption` becomes the event's `.content`. The bundle's
494    /// required fields (`url`, `mime_type`, `hash`) must be set, or
495    /// the call returns `Err(FileMetadataError::Missing*)`.
496    ///
497    /// # Errors
498    ///
499    /// Forwards [`FileMetadataError`] from the bundle-to-tags step.
500    pub fn file_metadata(
501        caption: impl Into<String>,
502        metadata: &FileMetadata,
503    ) -> Result<Self, FileMetadataError> {
504        let tags = metadata.to_tags()?;
505        let mut builder = Self::new(KIND_FILE_METADATA, caption);
506        for tag in tags {
507            builder = builder.tag(tag);
508        }
509        Ok(builder)
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::Keys;
517
518    const HASH_HEX: &str = "1aea8e98e0e5d969b7124f553b88dfae47d1f00472ea8c0dbf4ac4577d39ef02";
519    const ORIG_HEX: &str = "8a8c1d9c5b3e3e3a8b95d51b6f8a6f3a3a23bba1f1c5d9e7e1c0b3d8b9a0e3a4";
520    const URL: &str = "https://image.nostr.build/99a95fcb4b7a2591ad32467032c52a62d90a204d3b176bc2459ad7427a3f2b89.jpg";
521
522    fn keys() -> Keys {
523        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
524    }
525
526    fn fixture_metadata() -> FileMetadata {
527        let url = Url::parse(URL).unwrap();
528        let hash = hex_array(HASH_HEX);
529        FileMetadata::new(url, "image/jpeg", hash)
530            .original_hash(hex_array(ORIG_HEX))
531            .size(102_400)
532            .dim(ImageDimensions::new(640, 480).unwrap())
533            .magnet("magnet:?xt=urn:btih:abcd")
534            .torrent_infohash("abcd1234")
535            .blurhash("LE@:#0R%9F00ag~q-=^7^*9F8_-V")
536            .thumb(FileVariant::new(
537                Url::parse("https://example.com/t.jpg").unwrap(),
538            ))
539            .preview_image(
540                FileVariant::new(Url::parse("https://example.com/p.jpg").unwrap())
541                    .with_hash(hex_array(HASH_HEX)),
542            )
543            .summary("a sample image")
544            .alt("scenic mountain view")
545            .fallback(Url::parse("https://example.com/fallback1").unwrap())
546            .fallback(Url::parse("https://example.com/fallback2").unwrap())
547            .service("nip96")
548    }
549
550    fn hex_array(s: &str) -> [u8; 32] {
551        let mut out = [0_u8; 32];
552        hex::decode_to_slice(s, &mut out).unwrap();
553        out
554    }
555
556    #[test]
557    fn full_metadata_round_trips_through_event() {
558        let original = fixture_metadata();
559        let event = EventBuilder::file_metadata("caption text", &original)
560            .unwrap()
561            .sign_with_keys(&keys())
562            .unwrap();
563        assert_eq!(event.kind, KIND_FILE_METADATA);
564        assert_eq!(event.content, "caption text");
565
566        let parsed = FileMetadata::from_event(&event).unwrap();
567        assert_eq!(parsed, original);
568    }
569
570    #[test]
571    fn missing_url_is_rejected_when_parsing() {
572        let event = EventBuilder::new(KIND_FILE_METADATA, "")
573            .tag(letter(Alphabet::M, ["image/jpeg"]))
574            .tag(letter(Alphabet::X, [HASH_HEX]))
575            .sign_with_keys(&keys())
576            .unwrap();
577        assert!(matches!(
578            FileMetadata::from_event(&event),
579            Err(FileMetadataError::MissingUrl)
580        ));
581    }
582
583    #[test]
584    fn missing_mime_is_rejected_when_parsing() {
585        let event = EventBuilder::new(KIND_FILE_METADATA, "")
586            .tag(custom(URL_TAG, [URL]))
587            .tag(letter(Alphabet::X, [HASH_HEX]))
588            .sign_with_keys(&keys())
589            .unwrap();
590        assert!(matches!(
591            FileMetadata::from_event(&event),
592            Err(FileMetadataError::MissingMimeType)
593        ));
594    }
595
596    #[test]
597    fn missing_hash_is_rejected_when_parsing() {
598        let event = EventBuilder::new(KIND_FILE_METADATA, "")
599            .tag(custom(URL_TAG, [URL]))
600            .tag(letter(Alphabet::M, ["image/jpeg"]))
601            .sign_with_keys(&keys())
602            .unwrap();
603        assert!(matches!(
604            FileMetadata::from_event(&event),
605            Err(FileMetadataError::MissingHash)
606        ));
607    }
608
609    #[test]
610    fn malformed_hash_surfaces_typed_error() {
611        let event = EventBuilder::new(KIND_FILE_METADATA, "")
612            .tag(custom(URL_TAG, [URL]))
613            .tag(letter(Alphabet::M, ["image/jpeg"]))
614            .tag(letter(Alphabet::X, ["not-hex"]))
615            .sign_with_keys(&keys())
616            .unwrap();
617        assert!(matches!(
618            FileMetadata::from_event(&event),
619            Err(FileMetadataError::InvalidHashLength(_))
620        ));
621    }
622
623    #[test]
624    fn wrong_kind_is_rejected_when_parsing() {
625        let event = EventBuilder::text_note("nope")
626            .sign_with_keys(&keys())
627            .unwrap();
628        assert!(matches!(
629            FileMetadata::from_event(&event),
630            Err(FileMetadataError::WrongKind(_))
631        ));
632    }
633
634    #[test]
635    fn invalid_size_surfaces_typed_error() {
636        let event = EventBuilder::new(KIND_FILE_METADATA, "")
637            .tag(custom(URL_TAG, [URL]))
638            .tag(letter(Alphabet::M, ["image/jpeg"]))
639            .tag(letter(Alphabet::X, [HASH_HEX]))
640            .tag(custom(SIZE_TAG, ["abc"]))
641            .sign_with_keys(&keys())
642            .unwrap();
643        assert!(matches!(
644            FileMetadata::from_event(&event),
645            Err(FileMetadataError::InvalidSize(_))
646        ));
647    }
648
649    #[test]
650    fn invalid_dim_surfaces_typed_error() {
651        let event = EventBuilder::new(KIND_FILE_METADATA, "")
652            .tag(custom(URL_TAG, [URL]))
653            .tag(letter(Alphabet::M, ["image/jpeg"]))
654            .tag(letter(Alphabet::X, [HASH_HEX]))
655            .tag(custom(DIM_TAG, ["bad"]))
656            .sign_with_keys(&keys())
657            .unwrap();
658        assert!(matches!(
659            FileMetadata::from_event(&event),
660            Err(FileMetadataError::InvalidDim(_))
661        ));
662    }
663
664    #[test]
665    fn fallback_urls_round_trip_in_order() {
666        let metadata =
667            FileMetadata::new(Url::parse(URL).unwrap(), "image/jpeg", hex_array(HASH_HEX))
668                .fallback(Url::parse("https://a.example/").unwrap())
669                .fallback(Url::parse("https://b.example/").unwrap());
670        let event = EventBuilder::file_metadata("", &metadata)
671            .unwrap()
672            .sign_with_keys(&keys())
673            .unwrap();
674        let parsed = FileMetadata::from_event(&event).unwrap();
675        assert_eq!(parsed.fallback_urls.len(), 2);
676        assert_eq!(parsed.fallback_urls[0].as_str(), "https://a.example/");
677        assert_eq!(parsed.fallback_urls[1].as_str(), "https://b.example/");
678    }
679
680    #[test]
681    fn variant_without_hash_round_trips() {
682        let metadata =
683            FileMetadata::new(Url::parse(URL).unwrap(), "image/jpeg", hex_array(HASH_HEX))
684                .thumb(FileVariant::new(Url::parse("https://t.example/").unwrap()));
685        let event = EventBuilder::file_metadata("", &metadata)
686            .unwrap()
687            .sign_with_keys(&keys())
688            .unwrap();
689        let parsed = FileMetadata::from_event(&event).unwrap();
690        let thumb = parsed.thumb.unwrap();
691        assert_eq!(thumb.url.as_str(), "https://t.example/");
692        assert_eq!(thumb.hash, None);
693    }
694}