Skip to main content

nula_core/nips/
nip35.rs

1//! [NIP-35] Torrents.
2//!
3//! NIP-35 defines `kind: 2003`, a lightweight `BitTorrent` *index*: enough
4//! metadata to search for content and reconstruct a magnet link, without
5//! shipping the `.torrent` file itself. A companion `kind: 2004` event is a
6//! comment that "works exactly like a `kind: 1`" and follows NIP-10 tagging.
7//!
8//! Wire shape of a `kind: 2003` event:
9//!
10//! ```json
11//! {
12//!   "kind": 2003,
13//!   "content": "<free-form description>",
14//!   "tags": [
15//!     ["title", "<name>"],
16//!     ["x", "<v1 btih, 40 hex>"],
17//!     ["file", "<path>", "<size-bytes>"],
18//!     ["tracker", "udp://tracker.example:1337"],
19//!     ["i", "tcat:video,movie,4k"],
20//!     ["i", "imdb:tt15239678"],
21//!     ["t", "movie"]
22//!   ]
23//! }
24//! ```
25//!
26//! Compared with the upstream `rust-nostr` implementation, this module
27//! additionally provides [`Torrent::from_event`] (a strict parser; upstream
28//! ships only the builder) and preserves non-`tcat:` external references
29//! (`imdb:`, `tmdb:`, …) in [`Torrent::external_ids`] so they survive a
30//! round trip instead of being dropped.
31//!
32//! [NIP-35]: https://github.com/nostr-protocol/nips/blob/master/35.md
33//!
34//! # Example
35//!
36//! ```
37//! use nula_core::nips::nip35::{Torrent, TorrentFile, TorrentInfoHash};
38//! use nula_core::Keys;
39//!
40//! let torrent = Torrent {
41//!     title: "Example".to_owned(),
42//!     description: "An example torrent".to_owned(),
43//!     info_hash: TorrentInfoHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
44//!     files: vec![TorrentFile { name: "info/example.txt".to_owned(), size: 1024 }],
45//!     trackers: vec!["udp://tracker.example:1337".parse().unwrap()],
46//!     categories: vec!["video".to_owned(), "movie".to_owned()],
47//!     external_ids: vec!["imdb:tt15239678".to_owned()],
48//!     hashtags: vec!["movie".to_owned()],
49//! };
50//!
51//! let keys = Keys::generate().unwrap();
52//! let event = torrent.to_event_builder().sign_with_keys(&keys).unwrap();
53//! let parsed = Torrent::from_event(&event).unwrap();
54//! assert_eq!(parsed, torrent);
55//! ```
56
57use std::fmt;
58use std::str::FromStr;
59
60use thiserror::Error;
61
62use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
63use crate::types::{Url, UrlError};
64use crate::util::hex::{self, HexError};
65
66/// Length in bytes of a V1 (SHA-1) `BitTorrent` info hash.
67const INFO_HASH_LEN: usize = 20;
68
69/// `tcat:` prefix used by NIP-35 for the comma-separated category path.
70const TCAT_PREFIX: &str = "tcat:";
71
72/// A V1 `BitTorrent` info hash (the `btih` of a magnet link).
73///
74/// NIP-35 pins the `x` tag to the **V1** info hash, which is a 20-byte
75/// SHA-1 digest rendered as 40 lowercase hex characters. Modelling it as a
76/// fixed-size newtype (rather than a free-form `String`) rejects malformed
77/// hashes at the boundary and keeps comparisons/`Hash` cheap.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
79pub struct TorrentInfoHash([u8; INFO_HASH_LEN]);
80
81impl TorrentInfoHash {
82    /// Wrap a raw 20-byte digest.
83    #[must_use]
84    pub const fn from_byte_array(bytes: [u8; INFO_HASH_LEN]) -> Self {
85        Self(bytes)
86    }
87
88    /// Borrow the raw 20-byte digest.
89    #[must_use]
90    pub const fn as_byte_array(&self) -> &[u8; INFO_HASH_LEN] {
91        &self.0
92    }
93
94    /// Copy out the raw 20-byte digest.
95    #[must_use]
96    pub const fn to_byte_array(self) -> [u8; INFO_HASH_LEN] {
97        self.0
98    }
99
100    /// Parse from a 40-character lowercase hex string.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`HexError`] if the input is not exactly 40 hex characters.
105    pub fn from_hex<S>(input: S) -> Result<Self, HexError>
106    where
107        S: AsRef<str>,
108    {
109        let mut bytes = [0_u8; INFO_HASH_LEN];
110        hex::decode_to_slice(input.as_ref(), &mut bytes)?;
111        Ok(Self(bytes))
112    }
113
114    /// Render as a 40-character lowercase hex string.
115    #[must_use]
116    pub fn to_hex(self) -> String {
117        hex::encode(self.0)
118    }
119}
120
121impl fmt::Display for TorrentInfoHash {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        hex::fmt_lower(self.0, f)
124    }
125}
126
127impl FromStr for TorrentInfoHash {
128    type Err = HexError;
129
130    fn from_str(s: &str) -> Result<Self, Self::Err> {
131        Self::from_hex(s)
132    }
133}
134
135/// A single file entry within a torrent.
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
137pub struct TorrentFile {
138    /// Full path inside the torrent (e.g. `info/example.txt`).
139    pub name: String,
140    /// File size in bytes.
141    pub size: u64,
142}
143
144/// Torrent index metadata (`kind: 2003`).
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Torrent {
147    /// Human-readable title (`title` tag).
148    pub title: String,
149    /// Free-form description carried in the event `content`.
150    pub description: String,
151    /// V1 `BitTorrent` info hash (`x` tag).
152    pub info_hash: TorrentInfoHash,
153    /// Files included in the torrent (`file` tags).
154    pub files: Vec<TorrentFile>,
155    /// Tracker URLs (`tracker` tags).
156    pub trackers: Vec<Url>,
157    /// Category path segments encoded as a single `["i", "tcat:a,b,c"]` tag.
158    pub categories: Vec<String>,
159    /// Other `i` external references verbatim (`imdb:…`, `tmdb:…`, …).
160    pub external_ids: Vec<String>,
161    /// Additional hashtags (`t` tags).
162    pub hashtags: Vec<String>,
163}
164
165impl Torrent {
166    /// Build the `kind: 2003` [`EventBuilder`] for this torrent.
167    #[must_use]
168    pub fn to_event_builder(&self) -> EventBuilder {
169        let mut tags: Vec<Tag> = Vec::with_capacity(
170            2 + self.files.len()
171                + self.trackers.len()
172                + usize::from(!self.categories.is_empty())
173                + self.external_ids.len()
174                + self.hashtags.len(),
175        );
176
177        tags.push(Tag::title(self.title.as_str()));
178        tags.push(Tag::with(&info_hash_kind(), [self.info_hash.to_hex()]));
179
180        for file in &self.files {
181            tags.push(Tag::with(
182                &file_kind(),
183                [file.name.clone(), file.size.to_string()],
184            ));
185        }
186        for tracker in &self.trackers {
187            tags.push(Tag::with(&tracker_kind(), [tracker.as_str().to_owned()]));
188        }
189        if !self.categories.is_empty() {
190            tags.push(Tag::i(format!(
191                "{TCAT_PREFIX}{}",
192                self.categories.join(",")
193            )));
194        }
195        for external in &self.external_ids {
196            tags.push(Tag::i(external.clone()));
197        }
198        for hashtag in &self.hashtags {
199            tags.push(Tag::t(hashtag));
200        }
201
202        EventBuilder::new(Kind::TORRENT, self.description.as_str()).tags(Tags::from_vec(tags))
203    }
204
205    /// Reconstruct a [`Torrent`] from a `kind: 2003` [`Event`].
206    ///
207    /// Unknown tags are ignored (forward-compatible). Repeated `i` tags whose
208    /// value starts with `tcat:` are flattened across commas into
209    /// [`Torrent::categories`]; every other `i` tag is preserved verbatim in
210    /// [`Torrent::external_ids`].
211    ///
212    /// # Errors
213    ///
214    /// Returns [`TorrentError`] if the kind is not `2003`, a mandatory field
215    /// (`title`, `x`) is missing, or a `file` / `tracker` / `x` value is
216    /// malformed.
217    pub fn from_event(event: &Event) -> Result<Self, TorrentError> {
218        if event.kind != Kind::TORRENT {
219            return Err(TorrentError::UnexpectedKind {
220                expected: Kind::TORRENT.as_u16(),
221                got: event.kind.as_u16(),
222            });
223        }
224
225        let title = event
226            .tags
227            .find_first(&TagKind::custom("title"))
228            .and_then(Tag::content)
229            .ok_or(TorrentError::MissingTitle)?
230            .to_owned();
231
232        let info_hash = event
233            .tags
234            .find_first(&info_hash_kind())
235            .and_then(Tag::content)
236            .ok_or(TorrentError::MissingInfoHash)
237            .and_then(|hex| {
238                TorrentInfoHash::from_hex(hex).map_err(TorrentError::InvalidInfoHash)
239            })?;
240
241        let mut files = Vec::new();
242        for tag in event.tags.find_all(&file_kind()) {
243            let name = tag.get(1).ok_or(TorrentError::MissingFileName)?.to_owned();
244            let size = tag
245                .get(2)
246                .ok_or(TorrentError::MissingFileSize)?
247                .parse::<u64>()
248                .map_err(|_err| TorrentError::InvalidFileSize)?;
249            files.push(TorrentFile { name, size });
250        }
251
252        let mut trackers = Vec::new();
253        for tag in event.tags.find_all(&tracker_kind()) {
254            let url = tag.content().ok_or(TorrentError::MissingTrackerUrl)?;
255            trackers.push(Url::parse(url)?);
256        }
257
258        let mut categories = Vec::new();
259        let mut external_ids = Vec::new();
260        for tag in event.tags.find_letter(Alphabet::I) {
261            let Some(value) = tag.content() else {
262                continue;
263            };
264            if let Some(path) = value.strip_prefix(TCAT_PREFIX) {
265                categories.extend(
266                    path.split(',')
267                        .filter(|segment| !segment.is_empty())
268                        .map(str::to_owned),
269                );
270            } else {
271                external_ids.push(value.to_owned());
272            }
273        }
274
275        let hashtags = event
276            .tags
277            .find_letter(Alphabet::T)
278            .filter_map(Tag::content)
279            .map(str::to_owned)
280            .collect();
281
282        Ok(Self {
283            title,
284            description: event.content.clone(),
285            info_hash,
286            files,
287            trackers,
288            categories,
289            external_ids,
290            hashtags,
291        })
292    }
293}
294
295impl EventBuilder {
296    /// Build a NIP-35 torrent comment (`kind: 2004`) replying to `torrent`.
297    ///
298    /// Per the spec a torrent comment "works exactly like a `kind: 1`" and
299    /// follows NIP-10, so this attaches a root `e` marker pointing at the
300    /// torrent and a `p` tag crediting its author.
301    #[must_use]
302    pub fn torrent_comment<S>(content: S, torrent: &Event) -> Self
303    where
304        S: Into<String>,
305    {
306        Self::new(Kind::TORRENT_COMMENT, content)
307            .tag(Tag::e_marker(torrent.id, "", "root"))
308            .tag(Tag::p(torrent.pubkey))
309    }
310}
311
312const fn info_hash_kind() -> TagKind {
313    // `x` is a single-letter tag head, so it must be constructed (and
314    // matched) as `SingleLetter`, not `Custom`, to round-trip.
315    TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::X))
316}
317
318fn file_kind() -> TagKind {
319    TagKind::custom("file")
320}
321
322fn tracker_kind() -> TagKind {
323    TagKind::custom("tracker")
324}
325
326/// Errors raised when parsing a [`Torrent`] from an [`Event`].
327#[derive(Debug, Error)]
328#[non_exhaustive]
329pub enum TorrentError {
330    /// The event's kind was not `2003`.
331    #[error("expected kind {expected}, got {got}")]
332    UnexpectedKind {
333        /// `Kind::TORRENT.as_u16()`.
334        expected: u16,
335        /// What the event actually advertised.
336        got: u16,
337    },
338    /// The mandatory `title` tag was absent.
339    #[error("torrent event is missing the `title` tag")]
340    MissingTitle,
341    /// The mandatory `x` (info hash) tag was absent.
342    #[error("torrent event is missing the `x` info-hash tag")]
343    MissingInfoHash,
344    /// The `x` tag did not decode as a 20-byte hex digest.
345    #[error("invalid torrent info hash: {0}")]
346    InvalidInfoHash(#[source] HexError),
347    /// A `file` tag had no path element.
348    #[error("`file` tag is missing the file name")]
349    MissingFileName,
350    /// A `file` tag had no size element.
351    #[error("`file` tag is missing the file size")]
352    MissingFileSize,
353    /// A `file` tag's size did not parse as an unsigned integer.
354    #[error("`file` tag size is not a valid byte count")]
355    InvalidFileSize,
356    /// A `tracker` tag had no URL element.
357    #[error("`tracker` tag is missing the URL")]
358    MissingTrackerUrl,
359    /// A `tracker` tag's URL did not parse.
360    #[error(transparent)]
361    InvalidTracker(#[from] UrlError),
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::Keys;
368
369    fn keys() -> Keys {
370        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
371    }
372
373    fn sample() -> Torrent {
374        Torrent {
375            title: "Example Release".to_owned(),
376            description: "A description body".to_owned(),
377            info_hash: TorrentInfoHash::from_hex("0123456789abcdef0123456789abcdef01234567")
378                .unwrap(),
379            files: vec![
380                TorrentFile {
381                    name: "info/a.mkv".to_owned(),
382                    size: 1_048_576,
383                },
384                TorrentFile {
385                    name: "info/b.nfo".to_owned(),
386                    size: 512,
387                },
388            ],
389            trackers: vec![
390                "udp://tracker.example:1337".parse().unwrap(),
391                "http://tracker.example/announce".parse().unwrap(),
392            ],
393            categories: vec!["video".to_owned(), "movie".to_owned(), "4k".to_owned()],
394            external_ids: vec!["imdb:tt15239678".to_owned(), "tmdb:movie:693134".to_owned()],
395            hashtags: vec!["movie".to_owned(), "4k".to_owned()],
396        }
397    }
398
399    #[test]
400    fn info_hash_hex_round_trip() {
401        let hex = "0123456789abcdef0123456789abcdef01234567";
402        let hash = TorrentInfoHash::from_hex(hex).unwrap();
403        assert_eq!(hash.to_hex(), hex);
404        assert_eq!(hash.to_string(), hex);
405        assert_eq!(hex.parse::<TorrentInfoHash>().unwrap(), hash);
406    }
407
408    #[test]
409    fn info_hash_rejects_wrong_length() {
410        assert!(TorrentInfoHash::from_hex("abcd").is_err());
411        assert!(TorrentInfoHash::from_hex("zz").is_err());
412    }
413
414    #[test]
415    fn round_trip_through_event() {
416        let torrent = sample();
417        let event = torrent.to_event_builder().sign_with_keys(&keys()).unwrap();
418        event.verify().unwrap();
419        assert_eq!(event.kind, Kind::TORRENT);
420
421        let parsed = Torrent::from_event(&event).unwrap();
422        assert_eq!(parsed, torrent);
423    }
424
425    #[test]
426    fn categories_emitted_as_single_tcat_tag() {
427        let event = sample().to_event_builder().sign_with_keys(&keys()).unwrap();
428        let tcat: Vec<&str> = event
429            .tags
430            .find_letter(Alphabet::I)
431            .filter_map(Tag::content)
432            .filter(|v| v.starts_with(TCAT_PREFIX))
433            .collect();
434        assert_eq!(tcat, ["tcat:video,movie,4k"]);
435    }
436
437    #[test]
438    fn parses_multiple_tcat_tags_flattened() {
439        // An upstream encoder might emit one `i tcat:<x>` per category;
440        // the parser must flatten them just the same.
441        let event = EventBuilder::new(Kind::TORRENT, "body")
442            .tags(Tags::from_vec(vec![
443                Tag::title("T"),
444                Tag::with(
445                    &info_hash_kind(),
446                    ["0123456789abcdef0123456789abcdef01234567"],
447                ),
448                Tag::i("tcat:video"),
449                Tag::i("tcat:movie"),
450            ]))
451            .sign_with_keys(&keys())
452            .unwrap();
453        let parsed = Torrent::from_event(&event).unwrap();
454        assert_eq!(parsed.categories, ["video", "movie"]);
455    }
456
457    #[test]
458    fn wrong_kind_is_rejected() {
459        let event = EventBuilder::text_note("nope")
460            .sign_with_keys(&keys())
461            .unwrap();
462        let err = Torrent::from_event(&event).unwrap_err();
463        assert!(matches!(
464            err,
465            TorrentError::UnexpectedKind {
466                expected: 2_003,
467                got: 1
468            }
469        ));
470    }
471
472    #[test]
473    fn missing_title_is_rejected() {
474        let event = EventBuilder::new(Kind::TORRENT, "body")
475            .tag(Tag::with(
476                &info_hash_kind(),
477                ["0123456789abcdef0123456789abcdef01234567"],
478            ))
479            .sign_with_keys(&keys())
480            .unwrap();
481        assert!(matches!(
482            Torrent::from_event(&event).unwrap_err(),
483            TorrentError::MissingTitle
484        ));
485    }
486
487    #[test]
488    fn missing_info_hash_is_rejected() {
489        let event = EventBuilder::new(Kind::TORRENT, "body")
490            .tag(Tag::title("T"))
491            .sign_with_keys(&keys())
492            .unwrap();
493        assert!(matches!(
494            Torrent::from_event(&event).unwrap_err(),
495            TorrentError::MissingInfoHash
496        ));
497    }
498
499    #[test]
500    fn invalid_file_size_is_rejected() {
501        let event = EventBuilder::new(Kind::TORRENT, "body")
502            .tags(Tags::from_vec(vec![
503                Tag::title("T"),
504                Tag::with(
505                    &info_hash_kind(),
506                    ["0123456789abcdef0123456789abcdef01234567"],
507                ),
508                Tag::with(&file_kind(), ["info/a.mkv", "not-a-number"]),
509            ]))
510            .sign_with_keys(&keys())
511            .unwrap();
512        assert!(matches!(
513            Torrent::from_event(&event).unwrap_err(),
514            TorrentError::InvalidFileSize
515        ));
516    }
517
518    #[test]
519    fn torrent_comment_follows_nip10() {
520        let torrent_event = sample().to_event_builder().sign_with_keys(&keys()).unwrap();
521        let comment = EventBuilder::torrent_comment("nice release", &torrent_event)
522            .sign_with_keys(&keys())
523            .unwrap();
524        assert_eq!(comment.kind, Kind::TORRENT_COMMENT);
525
526        let e_tag = comment.tags.find_letter(Alphabet::E).next().unwrap();
527        assert_eq!(e_tag.get(1), Some(torrent_event.id.to_hex().as_str()));
528        assert_eq!(e_tag.get(3), Some("root"));
529
530        let p_tag = comment.tags.find_letter(Alphabet::P).next().unwrap();
531        assert_eq!(
532            p_tag.content(),
533            Some(torrent_event.pubkey.to_hex().as_str())
534        );
535    }
536}