Skip to main content

irontide_core/
detect.rs

1//! Auto-detection of torrent format (v1, v2, or hybrid).
2//!
3//! Inspects the info dict for `meta version` and `pieces` to distinguish:
4//! - V1: no `meta version = 2`
5//! - V2: `meta version = 2` without v1 `pieces` key
6//! - Hybrid: `meta version = 2` AND v1 `pieces` key present
7
8use irontide_bencode::BencodeValue;
9
10use crate::error::Error;
11use crate::info_hashes::InfoHashes;
12use crate::metainfo::TorrentMetaV1;
13use crate::metainfo_v2::TorrentMetaV2;
14use crate::torrent_version::TorrentVersion;
15
16/// A parsed torrent file — v1, v2, or hybrid.
17#[derive(Debug, Clone)]
18pub enum TorrentMeta {
19    /// `BitTorrent` v1 (BEP 3).
20    V1(TorrentMetaV1),
21    /// `BitTorrent` v2 (BEP 52).
22    V2(TorrentMetaV2),
23    /// Hybrid v1+v2 (BEP 52). Contains both metadata representations.
24    /// Boxed to avoid inflating the enum size for the common V1/V2 cases.
25    Hybrid(Box<TorrentMetaV1>, Box<TorrentMetaV2>),
26}
27
28impl TorrentMeta {
29    /// Get the unified info hashes.
30    pub fn info_hashes(&self) -> InfoHashes {
31        match self {
32            Self::V1(t) => InfoHashes::v1_only(t.info_hash),
33            Self::V2(t) => t.info_hashes.clone(),
34            Self::Hybrid(v1, v2) => InfoHashes::hybrid(
35                v1.info_hash,
36                v2.info_hashes.v2.expect("v2 torrent must have v2 hash"),
37            ),
38        }
39    }
40
41    /// Whether this is a v1 torrent.
42    pub fn is_v1(&self) -> bool {
43        matches!(self, Self::V1(_))
44    }
45
46    /// Whether this is a v2 torrent.
47    pub fn is_v2(&self) -> bool {
48        matches!(self, Self::V2(_))
49    }
50
51    /// Whether this is a hybrid v1+v2 torrent.
52    pub fn is_hybrid(&self) -> bool {
53        matches!(self, Self::Hybrid(_, _))
54    }
55
56    /// Get the protocol version enum.
57    pub fn version(&self) -> TorrentVersion {
58        match self {
59            Self::V1(_) => TorrentVersion::V1Only,
60            Self::V2(_) => TorrentVersion::V2Only,
61            Self::Hybrid(_, _) => TorrentVersion::Hybrid,
62        }
63    }
64
65    /// Access the v1 metadata, if present (V1 or Hybrid).
66    pub fn as_v1(&self) -> Option<&TorrentMetaV1> {
67        match self {
68            Self::V1(v1) => Some(v1),
69            Self::Hybrid(v1, _) => Some(v1),
70            Self::V2(_) => None,
71        }
72    }
73
74    /// Access the v2 metadata, if present (V2 or Hybrid).
75    pub fn as_v2(&self) -> Option<&TorrentMetaV2> {
76        match self {
77            Self::V2(v2) => Some(v2),
78            Self::Hybrid(_, v2) => Some(v2),
79            Self::V1(_) => None,
80        }
81    }
82
83    /// Get the best v1-compatible info hash for session identification.
84    ///
85    /// For v1 and hybrid: returns the v1 SHA-1 info hash.
86    /// For v2-only: returns the v2 SHA-256 hash truncated to 20 bytes.
87    pub fn best_v1_info_hash(&self) -> crate::hash::Id20 {
88        self.info_hashes().best_v1()
89    }
90
91    /// Get the SSL CA certificate bytes (PEM), if this is an SSL torrent.
92    pub fn ssl_cert(&self) -> Option<&[u8]> {
93        match self {
94            Self::V1(v1) => v1.ssl_cert.as_deref(),
95            Self::V2(v2) => v2.ssl_cert.as_deref(),
96            Self::Hybrid(v1, _) => v1.ssl_cert.as_deref(),
97        }
98    }
99
100    /// Whether this torrent requires SSL peer connections.
101    pub fn is_ssl(&self) -> bool {
102        self.ssl_cert().is_some()
103    }
104}
105
106impl From<TorrentMetaV1> for TorrentMeta {
107    fn from(meta: TorrentMetaV1) -> Self {
108        Self::V1(meta)
109    }
110}
111
112impl From<TorrentMetaV2> for TorrentMeta {
113    fn from(meta: TorrentMetaV2) -> Self {
114        Self::V2(meta)
115    }
116}
117
118/// Detected version of a .torrent file's info dict.
119enum DetectedVersion {
120    V1Only,
121    V2Only,
122    Hybrid,
123}
124
125/// Auto-detect and parse a .torrent file as v1, v2, or hybrid.
126///
127/// Detection:
128/// - `meta version = 2` AND `pieces` key present -> Hybrid
129/// - `meta version = 2` only -> V2
130/// - Otherwise -> V1
131///
132/// # Errors
133///
134/// Returns an error if the data is not a valid torrent file or cannot be parsed.
135pub fn torrent_from_bytes_any(data: &[u8]) -> Result<TorrentMeta, Error> {
136    match detect_version(data)? {
137        DetectedVersion::V1Only => Ok(TorrentMeta::V1(crate::metainfo::torrent_from_bytes(data)?)),
138        DetectedVersion::V2Only => Ok(TorrentMeta::V2(crate::metainfo_v2::torrent_v2_from_bytes(
139            data,
140        )?)),
141        DetectedVersion::Hybrid => {
142            let v1 = crate::metainfo::torrent_from_bytes(data)?;
143            let mut v2 = crate::metainfo_v2::torrent_v2_from_bytes(data)?;
144            // Override the truncated v1 hash with the REAL SHA-1 info hash.
145            // In hybrid torrents, the v1 hash is SHA-1 of the raw info dict,
146            // not a truncation of the SHA-256 hash.
147            v2.info_hashes.v1 = Some(v1.info_hash);
148            Ok(TorrentMeta::Hybrid(Box::new(v1), Box::new(v2)))
149        }
150    }
151}
152
153/// Detect the version of a torrent from its raw bencode bytes.
154fn detect_version(data: &[u8]) -> Result<DetectedVersion, Error> {
155    let root: BencodeValue = irontide_bencode::from_bytes(data)?;
156    let root_dict = root
157        .as_dict()
158        .ok_or_else(|| Error::InvalidTorrent("torrent must be a dict".into()))?;
159    let info = root_dict
160        .get(b"info".as_ref())
161        .and_then(|v| v.as_dict())
162        .ok_or_else(|| Error::InvalidTorrent("missing or invalid 'info' dict".into()))?;
163
164    let has_v2 = info
165        .get(b"meta version".as_ref())
166        .and_then(irontide_bencode::BencodeValue::as_int)
167        == Some(2);
168
169    let has_v1_pieces = info.get(b"pieces".as_ref()).is_some();
170
171    Ok(match (has_v2, has_v1_pieces) {
172        (true, true) => DetectedVersion::Hybrid,
173        (true, false) => DetectedVersion::V2Only,
174        _ => DetectedVersion::V1Only,
175    })
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn auto_detect_v1() {
184        // Build a v1 torrent
185        let data = b"d4:infod6:lengthi100e4:name4:test12:piece lengthi256e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00ee";
186        let meta = torrent_from_bytes_any(data).unwrap();
187        assert!(meta.is_v1());
188        assert!(!meta.is_v2());
189        assert!(!meta.is_hybrid());
190    }
191
192    #[test]
193    fn auto_detect_v2() {
194        use std::collections::BTreeMap;
195
196        // Build a minimal v2 torrent
197        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
198        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
199        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
200        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(100));
201        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
202        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
203        ft_map.insert(b"f.txt".to_vec(), BencodeValue::Dict(file_node));
204
205        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
206        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
207        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
208        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
209
210        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
211        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
212
213        let data = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
214        let meta = torrent_from_bytes_any(&data).unwrap();
215        assert!(meta.is_v2());
216        assert!(!meta.is_v1());
217        assert!(!meta.is_hybrid());
218    }
219
220    #[test]
221    fn auto_detect_hybrid() {
222        use std::collections::BTreeMap;
223
224        // Build a hybrid torrent: has both `pieces` (v1) and `meta version = 2` + `file tree` (v2)
225        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
226
227        // v2 keys
228        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
229        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
230        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
231        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
232        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
233        ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
234
235        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
236        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
237
238        // v1 keys
239        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
240        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
241        info_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
242        // 1 piece = 20 bytes of SHA-1 hash
243        info_map.insert(b"pieces".to_vec(), BencodeValue::Bytes(vec![0xAA; 20]));
244
245        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
246        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
247
248        let data = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
249        let meta = torrent_from_bytes_any(&data).unwrap();
250        assert!(meta.is_hybrid());
251        assert!(!meta.is_v1());
252        assert!(!meta.is_v2());
253
254        // Verify info_hashes has both v1 and v2
255        let hashes = meta.info_hashes();
256        assert!(hashes.has_v1());
257        assert!(hashes.has_v2());
258        assert!(hashes.is_hybrid());
259
260        // Verify the v1 hash is the REAL SHA-1, not a truncation of SHA-256
261        if let TorrentMeta::Hybrid(ref v1, ref v2) = meta {
262            // v1 info hash is SHA-1 of raw info dict bytes
263            assert_eq!(hashes.v1.unwrap(), v1.info_hash);
264            // v2 info hash is SHA-256 of same bytes — different from v1
265            assert!(hashes.v2.is_some());
266            assert_ne!(
267                &v1.info_hash.0[..],
268                &v2.info_hashes.v2.unwrap().0[..20],
269                "v1 hash should NOT be a truncation in hybrid — it's SHA-1 not SHA-256[:20]"
270            );
271        } else {
272            panic!("expected Hybrid variant");
273        }
274    }
275
276    #[test]
277    fn hybrid_version_accessor() {
278        let data = b"d4:infod6:lengthi100e4:name4:test12:piece lengthi256e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00ee";
279        let meta = torrent_from_bytes_any(data).unwrap();
280        assert_eq!(
281            meta.version(),
282            crate::torrent_version::TorrentVersion::V1Only
283        );
284    }
285
286    #[test]
287    fn enum_queries_work() {
288        let data = b"d4:infod6:lengthi100e4:name4:test12:piece lengthi256e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00ee";
289        let meta = torrent_from_bytes_any(data).unwrap();
290        let hashes = meta.info_hashes();
291        assert!(hashes.has_v1());
292        assert!(!hashes.has_v2());
293    }
294
295    #[test]
296    fn as_v1_accessor() {
297        let data = b"d4:infod6:lengthi100e4:name4:test12:piece lengthi256e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00ee";
298        let meta = torrent_from_bytes_any(data).unwrap();
299        assert!(meta.as_v1().is_some());
300        assert!(meta.as_v2().is_none());
301    }
302
303    #[test]
304    fn torrent_meta_ssl_accessors() {
305        // Non-SSL torrent
306        let data = b"d4:infod6:lengthi100e4:name4:test12:piece lengthi256e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00ee";
307        let meta = torrent_from_bytes_any(data).unwrap();
308        assert!(!meta.is_ssl());
309        assert!(meta.ssl_cert().is_none());
310    }
311}