use bt_bencode::Value as BencodeValue;
use fluent_uri::pct_enc::{
encoder::{Data, Query},
EStr, EString,
};
use rustc_hex::ToHex;
#[cfg(feature = "sea_orm")]
use sea_orm::prelude::*;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::{
InfoHash, InfoHashError, MagnetLink, MagnetLinkError, PieceLength, TorrentContent, TorrentID,
Tracker,
};
#[derive(Clone, Debug, PartialEq)]
pub enum TorrentFileError {
NoNameFound,
InvalidBencode { reason: String },
NotATorrent { reason: String },
WrongVersion { version: u64 },
InvalidHash { source: InfoHashError },
InvalidContentPath { path: String },
MissingPieceLength,
BadPieceLength { piece_length: u32 },
}
impl std::fmt::Display for TorrentFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TorrentFileError::NoNameFound => write!(f, "No name found"),
TorrentFileError::InvalidBencode { reason } => write!(f, "Invalid bencode: {reason}"),
TorrentFileError::NotATorrent { reason } => write!(
f,
"Valid bencode, but does not seem to be a torrent ({reason})"
),
TorrentFileError::WrongVersion { version } => write!(
f,
"Wrong torrent version: {version}, only v1 and v2 are supported)"
),
TorrentFileError::InvalidHash { source } => write!(f, "Invalid hash: {source}"),
TorrentFileError::InvalidContentPath { path } => {
write!(f, "Invalid content file path in torrent: {path}")
}
TorrentFileError::MissingPieceLength => {
write!(f, "No \'piece length\' field found in info dict")
}
TorrentFileError::BadPieceLength { piece_length } => {
write!(f, "Torrent \'piece length\' is too big: {}", piece_length)
}
}
}
}
impl From<InfoHashError> for TorrentFileError {
fn from(e: InfoHashError) -> TorrentFileError {
TorrentFileError::InvalidHash { source: e }
}
}
impl From<bt_bencode::Error> for TorrentFileError {
fn from(e: bt_bencode::Error) -> TorrentFileError {
TorrentFileError::InvalidBencode {
reason: e.to_string(),
}
}
}
impl std::error::Error for TorrentFileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
TorrentFileError::InvalidHash { source } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TorrentFile {
pub hash: InfoHash,
pub name: String,
pub decoded: DecodedTorrent,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DecodedTorrent {
#[serde(skip_serializing_if = "Option::is_none")]
announce: Option<Tracker>,
#[serde(
rename = "announce-list",
default,
skip_serializing_if = "Vec::is_empty"
)]
announce_list: Vec<Vec<Tracker>>,
info: DecodedInfo,
#[serde(flatten)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
extra: HashMap<String, BencodeValue>,
}
impl DecodedTorrent {
pub fn files(&self) -> Result<Vec<TorrentContent>, TorrentFileError> {
if let Some(info_files) = &self.info.files {
let mut files: Vec<TorrentContent> = vec![];
for file in info_files {
let f: UnsafeV1FileContent = bt_bencode::from_value(file.clone()).unwrap();
if let Some(parsed_file) = f.to_torrent_content()? {
files.push(parsed_file);
}
}
files.sort();
return Ok(files);
}
if let Some(_info_file_tree) = &self.info.file_tree {
todo!("v2 torrent files");
}
Ok(vec![TorrentContent {
path: PathBuf::from(&self.info.name),
size: self.info.length.unwrap(),
}])
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)]
pub struct UnsafeV1FileContent {
#[serde(rename = "path")]
pub raw_paths: Vec<String>,
pub length: u64,
#[serde(default)]
pub attr: String,
}
impl UnsafeV1FileContent {
pub fn to_torrent_content(&self) -> Result<Option<TorrentContent>, TorrentFileError> {
if self.attr.contains('p') {
return Ok(None);
}
let mut path = PathBuf::new();
for p in &self.raw_paths {
if p.contains('/') {
return Err(TorrentFileError::InvalidContentPath {
path: p.to_string(),
});
}
if p == ".." {
return Err(TorrentFileError::InvalidContentPath {
path: p.to_string(),
});
}
if p == "." {
continue;
}
path.push(p);
}
Ok(Some(TorrentContent {
path,
size: self.length,
}))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DecodedInfo {
#[serde(rename = "meta version")]
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<u64>,
name: String,
#[serde(rename = "piece length")]
piece_length: PieceLength,
#[serde(skip_serializing_if = "Option::is_none")]
length: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
files: Option<Vec<BencodeValue>>,
#[serde(rename = "file tree")]
#[serde(skip_serializing_if = "Option::is_none")]
file_tree: Option<BencodeValue>,
#[serde(flatten)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
extra: HashMap<String, BencodeValue>,
}
impl TorrentFile {
pub fn to_vec(&self) -> Vec<u8> {
bt_bencode::to_vec(&self.decoded).unwrap()
}
pub fn from_slice(s: &[u8]) -> Result<TorrentFile, TorrentFileError> {
let torrent: DecodedTorrent = bt_bencode::from_slice(s).map_err(|e| {
TorrentFileError::NotATorrent {
reason: e.to_string(),
}
})?;
let info_bytes = bt_bencode::to_vec(&torrent.info).unwrap();
let infohash = match torrent.info.version {
Some(1) | None => {
let digest = Sha1::digest(&info_bytes).to_vec().to_hex::<String>();
InfoHash::new(&digest)?
}
Some(2) => {
if torrent.info.file_tree.is_some() {
let digest = sha256::digest(info_bytes.as_slice());
let hash = InfoHash::new(&digest)?;
if torrent.info.length.is_some() || torrent.info.files.is_some() {
let digest = Sha1::digest(&info_bytes).to_vec().to_hex::<String>();
hash.hybrid(&InfoHash::new(&digest)?)?
} else {
hash
}
} else {
return Err(TorrentFileError::NotATorrent {
reason: "Torrentv2 without 'file_tree' field".to_string(),
});
}
}
_ => {
return Err(TorrentFileError::WrongVersion {
version: torrent.info.version.unwrap(),
});
}
};
Ok(TorrentFile {
name: torrent.info.name.clone(),
hash: infohash,
decoded: torrent,
})
}
pub fn hash(&self) -> &str {
self.hash.as_str()
}
pub fn name(&self) -> &str {
&self.name
}
pub fn id(&self) -> TorrentID {
TorrentID::from_infohash(&self.hash)
}
pub fn trackers(&self) -> Vec<Tracker> {
let mut trackers = vec![];
if let Some(tracker) = &self.decoded.announce {
trackers.push(tracker.clone());
}
for tier in &self.decoded.announce_list {
for tracker in tier {
trackers.push(tracker.clone());
}
}
trackers.sort_unstable();
trackers.dedup();
trackers
}
pub fn magnet_link(&self) -> Result<MagnetLink, MagnetLinkError> {
let mut uri = match &self.hash {
InfoHash::V1(h) => format!("magnet:?xt=urn:btih:{h}"),
InfoHash::V2(h) => format!("magnet:?xt=urn:btmh:1220{h}"),
InfoHash::Hybrid((h1, h2)) => format!("magnet:?xt=urn:btih:{h1}&xt=urn:btmh:1220{h2}"),
};
let mut buf = EString::<Query>::new();
if !self.name.is_empty() {
buf.push_estr(EStr::new_or_panic("&dn="));
buf.encode_str::<Data>(&self.name);
}
for tracker in self.trackers() {
buf.push_estr(EStr::new_or_panic("&tr="));
buf.encode_str::<Data>(tracker.url());
}
uri.push_str(buf.as_str());
MagnetLink::new(&uri)
}
}
#[cfg(feature = "sea_orm")]
impl From<TorrentFile> for sea_orm::sea_query::Value {
fn from(t: TorrentFile) -> Self {
Value::Bytes(Some(t.to_vec()))
}
}
#[cfg(feature = "sea_orm")]
impl sea_orm::TryGetable for TorrentFile {
fn try_get_by<I: sea_orm::ColIdx>(
res: &sea_orm::QueryResult,
index: I,
) -> Result<Self, sea_orm::error::TryGetError> {
let val = <Vec<u8> as sea_orm::TryGetable>::try_get_by(res, index)?;
TorrentFile::from_slice(&val).map_err(|e| {
sea_orm::error::TryGetError::DbErr(sea_orm::DbErr::TryIntoErr {
from: "Bytes",
into: "TorrentFile",
source: std::sync::Arc::new(e),
})
})
}
}
#[cfg(feature = "sea_orm")]
impl sea_orm::sea_query::ValueType for TorrentFile {
fn try_from(v: sea_orm::Value) -> Result<Self, sea_orm::sea_query::ValueTypeErr> {
match v {
sea_orm::Value::Bytes(Some(s)) => {
TorrentFile::from_slice(&s).map_err(|_e| sea_orm::sea_query::ValueTypeErr)
}
_ => Err(sea_orm::sea_query::ValueTypeErr),
}
}
fn type_name() -> String {
"TorrentFile".to_string()
}
fn array_type() -> sea_orm::sea_query::ArrayType {
sea_orm::sea_query::ArrayType::Bytes
}
fn column_type() -> sea_orm::sea_query::ColumnType {
sea_orm::sea_query::ColumnType::VarBinary(StringLen::None)
}
}
#[cfg(feature = "sea_orm")]
impl sea_orm::sea_query::Nullable for TorrentFile {
fn null() -> sea_orm::sea_query::Value {
sea_orm::sea_query::Value::Bytes(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn can_read_torrent_v1_multifile() {
let slice = std::fs::read("tests/bittorrent-v1-emma-goldman.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
println!("{:?}", res);
assert!(res.is_ok());
let torrent = res.unwrap();
assert_eq!(
&torrent.name,
"Goldman, Emma - Essential Works of Anarchism"
);
assert_eq!(
torrent.hash,
InfoHash::V1("c811b41641a09d192b8ed81b14064fff55d85ce3".to_string())
);
assert_eq!(torrent.decoded.files().unwrap().len(), 94);
}
#[test]
#[cfg(not(feature = "unknown_tracker_scheme"))]
fn fail_no_torrent_scheme() {
let slice = std::fs::read("tests/libtorrent/good/sample.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
println!("{:?}", res);
assert!(res.is_err());
assert_eq!(
res.unwrap_err(),
TorrentFileError::NotATorrent {
reason: "Invalid scheme: tracker.publicbt.com".to_string()
},
);
}
#[test]
fn can_read_torrent_v1_wrongpath() {
let slice = std::fs::read("tests/libtorrent/good/parent_path.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
println!("{:?}", res);
assert!(res.is_ok());
let torrent = res.unwrap();
assert_eq!(&torrent.name, "temp");
assert_eq!(
torrent.hash,
InfoHash::V1("9e1111f1ee4966f7d06d398f1d58e00ad150657a".to_string())
);
assert_eq!(
torrent.decoded.files().unwrap_err(),
TorrentFileError::InvalidContentPath {
path: "..".to_string()
},
);
}
#[test]
fn can_read_torrent_v1_singlepath() {
let slice = std::fs::read("tests/libtorrent/good/base.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
println!("{:?}", res);
assert!(res.is_ok());
let torrent = res.unwrap();
assert_eq!(&torrent.name, "temp");
assert_eq!(
torrent.hash,
InfoHash::V1("c0fda1edafdbdbb96443424e0b3899af7159d10e".to_string())
);
assert_eq!(
torrent.decoded.files().unwrap(),
vec!(TorrentContent {
path: PathBuf::from("temp"),
size: 425,
}),
);
}
#[test]
fn can_read_torrent_v2() {
let slice = std::fs::read("tests/bittorrent-v2-test.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
assert!(res.is_ok());
let torrent = res.unwrap();
assert_eq!(&torrent.name, "bittorrent-v2-test");
assert_eq!(
torrent.hash,
InfoHash::V2(
"caf1e1c30e81cb361b9ee167c4aa64228a7fa4fa9f6105232b28ad099f3a302e".to_string()
)
);
}
#[test]
fn can_read_torrent_hybrid() {
let slice = std::fs::read("tests/bittorrent-v2-hybrid-test.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
assert!(res.is_ok());
let torrent = res.unwrap();
assert_eq!(&torrent.name, "bittorrent-v1-v2-hybrid-test");
assert_eq!(
torrent.hash,
InfoHash::Hybrid((
"631a31dd0a46257d5078c0dee4e66e26f73e42ac".to_string(),
"d8dd32ac93357c368556af3ac1d95c9d76bd0dff6fa9833ecdac3d53134efabb".to_string()
))
);
}
#[test]
fn v1_piece_len() {
let slice = std::fs::read("tests/libtorrent/bad/negative_piece_len.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
assert!(res.is_err());
}
#[test]
fn v2_piece_len() {
let slice = std::fs::read("tests/libtorrent/bad/v2_piece_size.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
assert!(res.is_err());
}
#[test]
fn test_torrent_to_magnet_v2() {
let slice = std::fs::read("tests/bittorrent-v2-test.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
let torrent = res.unwrap();
let expected = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap();
let magnet = torrent.magnet_link().unwrap();
assert_eq!(expected, magnet.to_string(),);
}
#[test]
fn test_torrent_to_magnet_hybrid() {
let slice = std::fs::read("tests/bittorrent-v2-hybrid-test.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
let torrent = res.unwrap();
let expected = std::fs::read_to_string("tests/bittorrent-v2-hybrid-test.magnet").unwrap();
let magnet = torrent.magnet_link().unwrap();
assert_eq!(expected, magnet.to_string(),);
}
#[test]
fn test_torrent_to_magnet_v1() {
let slice = std::fs::read("tests/bittorrent-v1-emma-goldman.torrent").unwrap();
let res = TorrentFile::from_slice(&slice);
let torrent = res.unwrap();
let expected = MagnetLink::new(
&std::fs::read_to_string("tests/bittorrent-v1-emma-goldman.magnet").unwrap(),
)
.unwrap();
let magnet = torrent.magnet_link().unwrap();
assert_eq!(expected.name(), magnet.name(),);
assert_eq!(expected.hash(), magnet.hash(),);
for tracker in magnet.trackers() {
if magnet.trackers().iter().filter(|x| x == &tracker).count() > 1 {
panic!("Duplicate tracker: {tracker:?}");
}
}
assert_eq!(magnet.trackers(), expected.trackers());
assert_eq!(expected, magnet);
}
}