use std::collections::BTreeMap;
use std::path::PathBuf;
use bytes::Bytes;
use sha1::{Digest, Sha1};
use sha2::Sha256;
use super::error::MetainfoError;
use super::file_tree::FileTree;
use super::info_hash::{InfoHash, InfoHashV1, InfoHashV2};
use crate::bencode::{decode, encode, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TorrentVersion {
V1,
V2,
Hybrid,
}
impl TorrentVersion {
pub fn supports_v1(&self) -> bool {
matches!(self, TorrentVersion::V1 | TorrentVersion::Hybrid)
}
pub fn supports_v2(&self) -> bool {
matches!(self, TorrentVersion::V2 | TorrentVersion::Hybrid)
}
}
#[derive(Debug, Clone)]
pub struct PieceLayers {
pub layers: BTreeMap<[u8; 32], Vec<[u8; 32]>>,
}
impl PieceLayers {
pub fn new() -> Self {
Self {
layers: BTreeMap::new(),
}
}
pub fn get(&self, pieces_root: &[u8; 32]) -> Option<&Vec<[u8; 32]>> {
self.layers.get(pieces_root)
}
pub fn file_count(&self) -> usize {
self.layers.len()
}
}
impl Default for PieceLayers {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub enum PieceHashes {
V1(Vec<[u8; 20]>),
V2(PieceLayers),
Hybrid {
v1: Vec<[u8; 20]>,
v2: PieceLayers,
},
}
impl PieceHashes {
pub fn v1_piece_count(&self) -> Option<usize> {
match self {
PieceHashes::V1(pieces) => Some(pieces.len()),
PieceHashes::Hybrid { v1, .. } => Some(v1.len()),
PieceHashes::V2(_) => None,
}
}
pub fn v1_pieces(&self) -> Option<&Vec<[u8; 20]>> {
match self {
PieceHashes::V1(pieces) => Some(pieces),
PieceHashes::Hybrid { v1, .. } => Some(v1),
PieceHashes::V2(_) => None,
}
}
pub fn v2_layers(&self) -> Option<&PieceLayers> {
match self {
PieceHashes::V2(layers) => Some(layers),
PieceHashes::Hybrid { v2, .. } => Some(v2),
PieceHashes::V1(_) => None,
}
}
pub fn has_v1(&self) -> bool {
matches!(self, PieceHashes::V1(_) | PieceHashes::Hybrid { .. })
}
pub fn has_v2(&self) -> bool {
matches!(self, PieceHashes::V2(_) | PieceHashes::Hybrid { .. })
}
}
#[derive(Debug, Clone)]
pub struct Metainfo {
pub info: Info,
pub info_hash: InfoHash,
pub announce: Option<String>,
pub announce_list: Vec<Vec<String>>,
pub creation_date: Option<i64>,
pub comment: Option<String>,
pub created_by: Option<String>,
pub version: TorrentVersion,
pub url_list: Vec<String>,
raw_info: Bytes,
}
#[derive(Debug, Clone)]
pub struct Info {
pub name: String,
pub piece_length: u64,
pub pieces: PieceHashes,
pub files: Vec<File>,
pub total_length: u64,
pub private: bool,
pub meta_version: Option<u8>,
}
impl Info {
pub fn piece_count(&self) -> usize {
match &self.pieces {
PieceHashes::V1(pieces) => pieces.len(),
PieceHashes::Hybrid { v1, .. } => v1.len(),
PieceHashes::V2(_) => {
self.files
.iter()
.map(|f| {
if f.length == 0 {
0
} else {
f.length.div_ceil(self.piece_length) as usize
}
})
.sum()
}
}
}
pub fn is_v1(&self) -> bool {
matches!(self.pieces, PieceHashes::V1(_))
}
pub fn is_v2(&self) -> bool {
matches!(self.pieces, PieceHashes::V2(_))
}
pub fn is_hybrid(&self) -> bool {
matches!(self.pieces, PieceHashes::Hybrid { .. })
}
pub fn supports_v1(&self) -> bool {
self.pieces.has_v1()
}
pub fn supports_v2(&self) -> bool {
self.pieces.has_v2()
}
pub fn get_v1_piece_hash(&self, piece_index: usize) -> Option<[u8; 20]> {
match &self.pieces {
PieceHashes::V1(pieces) => pieces.get(piece_index).copied(),
PieceHashes::Hybrid { v1, .. } => v1.get(piece_index).copied(),
PieceHashes::V2(_) => None,
}
}
pub fn get_v2_piece_hash(&self, file_index: usize, piece_index: usize) -> Option<[u8; 32]> {
let file = self.files.get(file_index)?;
let pieces_root = file.pieces_root?;
let layers = match &self.pieces {
PieceHashes::V2(layers) => layers,
PieceHashes::Hybrid { v2, .. } => v2,
PieceHashes::V1(_) => return None,
};
let layer_hashes = layers.get(&pieces_root)?;
layer_hashes.get(piece_index).copied()
}
pub fn get_file_pieces_root(&self, file_index: usize) -> Option<[u8; 32]> {
self.files.get(file_index).and_then(|f| f.pieces_root)
}
pub fn file_piece_count(&self, file_index: usize) -> usize {
self.files
.get(file_index)
.map(|f| {
if f.length == 0 {
0
} else {
f.length.div_ceil(self.piece_length) as usize
}
})
.unwrap_or(0)
}
pub fn content_files(&self) -> impl Iterator<Item = &File> {
self.files.iter().filter(|f| !f.is_padding())
}
pub fn padding_files(&self) -> impl Iterator<Item = &File> {
self.files.iter().filter(|f| f.is_padding())
}
pub fn get_v2_piece_hash_global(&self, global_piece_index: usize) -> Option<[u8; 32]> {
let layers = match &self.pieces {
PieceHashes::V2(layers) => layers,
PieceHashes::Hybrid { v2, .. } => v2,
PieceHashes::V1(_) => return None,
};
let mut piece_offset = 0usize;
for file in &self.files {
if file.length == 0 || file.is_padding() {
continue;
}
let file_pieces = file.length.div_ceil(self.piece_length) as usize;
if global_piece_index < piece_offset + file_pieces {
let local_index = global_piece_index - piece_offset;
let pieces_root = file.pieces_root?;
if file_pieces == 1 {
return Some(pieces_root);
}
let layer_hashes = layers.get(&pieces_root)?;
return layer_hashes.get(local_index).copied();
}
piece_offset += file_pieces;
}
None
}
pub fn all_v2_piece_hashes(&self) -> Vec<[u8; 32]> {
let layers = match &self.pieces {
PieceHashes::V2(layers) => layers,
PieceHashes::Hybrid { v2, .. } => v2,
PieceHashes::V1(_) => return Vec::new(),
};
let mut hashes = Vec::with_capacity(self.piece_count());
for file in &self.files {
if file.length == 0 || file.is_padding() {
continue;
}
let file_pieces = file.length.div_ceil(self.piece_length) as usize;
if let Some(pieces_root) = file.pieces_root {
if file_pieces == 1 {
hashes.push(pieces_root);
} else if let Some(layer_hashes) = layers.get(&pieces_root) {
hashes.extend(layer_hashes.iter().copied());
}
}
}
hashes
}
}
#[derive(Debug, Clone)]
pub struct File {
pub path: PathBuf,
pub length: u64,
pub offset: u64,
pub pieces_root: Option<[u8; 32]>,
pub attr: Option<String>,
}
impl File {
pub fn is_padding(&self) -> bool {
self.attr.as_ref().is_some_and(|a| a.contains('p'))
}
pub fn is_executable(&self) -> bool {
self.attr.as_ref().is_some_and(|a| a.contains('x'))
}
pub fn is_hidden(&self) -> bool {
self.attr.as_ref().is_some_and(|a| a.contains('h'))
}
}
impl Metainfo {
pub fn from_bytes(data: &[u8]) -> Result<Self, MetainfoError> {
let value = decode(data)?;
let dict = value.as_dict().ok_or(MetainfoError::InvalidField("root"))?;
let info_value = dict
.get(b"info".as_slice())
.ok_or(MetainfoError::MissingField("info"))?;
let info_dict = info_value
.as_dict()
.ok_or(MetainfoError::InvalidField("info"))?;
let raw_info = Bytes::from(encode(info_value)?);
let has_pieces = info_dict.get(b"pieces".as_slice()).is_some();
let has_file_tree = info_dict.get(b"file tree".as_slice()).is_some();
let meta_version = info_dict
.get(b"meta version".as_slice())
.and_then(|v| v.as_integer());
let version = match (has_pieces, has_file_tree, meta_version) {
(true, true, _) => TorrentVersion::Hybrid,
(false, true, Some(2)) => TorrentVersion::V2,
(true, false, _) => TorrentVersion::V1,
_ => TorrentVersion::V1, };
let info_hash = match version {
TorrentVersion::V1 => compute_info_hash(&raw_info),
TorrentVersion::V2 => compute_v2_info_hash(&raw_info),
TorrentVersion::Hybrid => {
let v1 = InfoHashV1::from_info_bytes(&raw_info);
let v2 = InfoHashV2::from_info_bytes(&raw_info);
InfoHash::hybrid(v1, v2)
}
};
let info = match version {
TorrentVersion::V1 => parse_info_v1(info_value)?,
TorrentVersion::V2 => {
let piece_layers = parse_piece_layers(dict)?;
parse_info_v2(info_value, piece_layers)?
}
TorrentVersion::Hybrid => {
let piece_layers = parse_piece_layers(dict)?;
parse_info_hybrid(info_value, piece_layers)?
}
};
let announce = dict
.get(b"announce".as_slice())
.and_then(|v| v.as_str())
.map(String::from);
let announce_list = dict
.get(b"announce-list".as_slice())
.and_then(|v| v.as_list())
.map(|list| {
list.iter()
.filter_map(|tier| {
tier.as_list().map(|urls| {
urls.iter()
.filter_map(|u| u.as_str().map(String::from))
.collect()
})
})
.collect()
})
.unwrap_or_default();
let creation_date = dict
.get(b"creation date".as_slice())
.and_then(|v| v.as_integer());
let comment = dict
.get(b"comment".as_slice())
.and_then(|v| v.as_str())
.map(String::from);
let created_by = dict
.get(b"created by".as_slice())
.and_then(|v| v.as_str())
.map(String::from);
let url_list = match dict.get(b"url-list".as_slice()) {
Some(Value::Bytes(url)) => {
String::from_utf8_lossy(url)
.to_string()
.split_whitespace()
.map(String::from)
.collect()
}
Some(Value::List(urls)) => {
urls.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
}
_ => Vec::new(),
};
Ok(Self {
info,
info_hash,
announce,
announce_list,
creation_date,
comment,
created_by,
version,
url_list,
raw_info,
})
}
pub fn raw_info(&self) -> &Bytes {
&self.raw_info
}
pub fn trackers(&self) -> Vec<String> {
let mut trackers = Vec::new();
if let Some(ref announce) = self.announce {
trackers.push(announce.clone());
}
for tier in &self.announce_list {
for tracker in tier {
if !trackers.contains(tracker) {
trackers.push(tracker.clone());
}
}
}
trackers
}
pub fn is_v2(&self) -> bool {
matches!(self.version, TorrentVersion::V2)
}
pub fn is_hybrid(&self) -> bool {
matches!(self.version, TorrentVersion::Hybrid)
}
}
fn parse_info_v1(value: &Value) -> Result<Info, MetainfoError> {
let dict = value.as_dict().ok_or(MetainfoError::InvalidField("info"))?;
let name = dict
.get(b"name".as_slice())
.and_then(|v| v.as_str())
.ok_or(MetainfoError::MissingField("name"))?
.to_string();
let piece_length = dict
.get(b"piece length".as_slice())
.and_then(|v| v.as_integer())
.ok_or(MetainfoError::MissingField("piece length"))? as u64;
let pieces_bytes = dict
.get(b"pieces".as_slice())
.and_then(|v| v.as_bytes())
.ok_or(MetainfoError::MissingField("pieces"))?;
if pieces_bytes.len() % 20 != 0 {
return Err(MetainfoError::InvalidField("pieces"));
}
let pieces: Vec<[u8; 20]> = pieces_bytes
.chunks_exact(20)
.map(|chunk| {
let mut arr = [0u8; 20];
arr.copy_from_slice(chunk);
arr
})
.collect();
let private = dict
.get(b"private".as_slice())
.and_then(|v| v.as_integer())
.map(|v| v == 1)
.unwrap_or(false);
let (files, total_length) = if let Some(length) =
dict.get(b"length".as_slice()).and_then(|v| v.as_integer())
{
let length = length as u64;
let file = File {
path: PathBuf::from(&name),
length,
offset: 0,
pieces_root: None,
attr: None,
};
(vec![file], length)
} else if let Some(files_list) = dict.get(b"files".as_slice()).and_then(|v| v.as_list()) {
let mut files = Vec::new();
let mut offset = 0u64;
for file_value in files_list {
let file_dict = file_value
.as_dict()
.ok_or(MetainfoError::InvalidField("files"))?;
let length = file_dict
.get(b"length".as_slice())
.and_then(|v| v.as_integer())
.ok_or(MetainfoError::MissingField("file length"))? as u64;
let path_list = file_dict
.get(b"path".as_slice())
.and_then(|v| v.as_list())
.ok_or(MetainfoError::MissingField("file path"))?;
let path: PathBuf = std::iter::once(name.clone())
.chain(
path_list
.iter()
.filter_map(|p| p.as_str().map(String::from)),
)
.collect();
let attr = file_dict
.get(b"attr".as_slice())
.and_then(|v| v.as_str())
.map(String::from);
files.push(File {
path,
length,
offset,
pieces_root: None,
attr,
});
offset += length;
}
let total = offset;
(files, total)
} else {
return Err(MetainfoError::MissingField("length or files"));
};
Ok(Info {
name,
piece_length,
pieces: PieceHashes::V1(pieces),
files,
total_length,
private,
meta_version: None,
})
}
fn parse_piece_layers(dict: &BTreeMap<Bytes, Value>) -> Result<PieceLayers, MetainfoError> {
let layers_value = match dict.get(b"piece layers".as_slice()) {
Some(v) => v,
None => return Ok(PieceLayers::new()), };
let layers_dict = layers_value
.as_dict()
.ok_or(MetainfoError::InvalidField("piece layers"))?;
let mut layers = BTreeMap::new();
for (key, value) in layers_dict {
if key.len() != 32 {
return Err(MetainfoError::InvalidField("piece layers key"));
}
let mut root = [0u8; 32];
root.copy_from_slice(key);
let hashes_bytes = value
.as_bytes()
.ok_or(MetainfoError::InvalidField("piece layers value"))?;
if hashes_bytes.len() % 32 != 0 {
return Err(MetainfoError::InvalidField("piece layers hash length"));
}
let hashes: Vec<[u8; 32]> = hashes_bytes
.chunks_exact(32)
.map(|chunk| {
let mut arr = [0u8; 32];
arr.copy_from_slice(chunk);
arr
})
.collect();
layers.insert(root, hashes);
}
Ok(PieceLayers { layers })
}
fn validate_piece_length(piece_length: u64) -> Result<(), MetainfoError> {
const MIN_PIECE_LENGTH: u64 = 16384;
if piece_length < MIN_PIECE_LENGTH {
return Err(MetainfoError::InvalidField("piece length too small"));
}
if piece_length & (piece_length - 1) != 0 {
return Err(MetainfoError::InvalidField("piece length not power of 2"));
}
Ok(())
}
fn validate_path_component(component: &str) -> Result<(), MetainfoError> {
if component == "." || component == ".." {
return Err(MetainfoError::InvalidField("path traversal detected"));
}
if component.is_empty() {
return Err(MetainfoError::InvalidField("empty path component"));
}
Ok(())
}
fn parse_info_v2(value: &Value, piece_layers: PieceLayers) -> Result<Info, MetainfoError> {
let dict = value.as_dict().ok_or(MetainfoError::InvalidField("info"))?;
let name = dict
.get(b"name".as_slice())
.and_then(|v| v.as_str())
.ok_or(MetainfoError::MissingField("name"))?
.to_string();
validate_path_component(&name)?;
let piece_length = dict
.get(b"piece length".as_slice())
.and_then(|v| v.as_integer())
.ok_or(MetainfoError::MissingField("piece length"))? as u64;
validate_piece_length(piece_length)?;
let meta_version = dict
.get(b"meta version".as_slice())
.and_then(|v| v.as_integer())
.map(|v| v as u8);
if meta_version != Some(2) {
return Err(MetainfoError::InvalidField("meta version must be 2"));
}
let private = dict
.get(b"private".as_slice())
.and_then(|v| v.as_integer())
.map(|v| v == 1)
.unwrap_or(false);
let file_tree_value = dict
.get(b"file tree".as_slice())
.ok_or(MetainfoError::MissingField("file tree"))?;
let file_tree = FileTree::from_bencode(file_tree_value)?;
let flattened = file_tree.flatten();
let mut files = Vec::new();
let mut offset = 0u64;
let mut total_length = 0u64;
for flat_file in flattened {
for component in flat_file.path.components() {
if let std::path::Component::Normal(s) = component {
if let Some(s) = s.to_str() {
validate_path_component(s)?;
}
}
}
let path = PathBuf::from(&name).join(&flat_file.path);
if flat_file.length > 0 {
if let Some(root) = &flat_file.pieces_root {
if flat_file.length > piece_length && !piece_layers.layers.contains_key(root) {
return Err(MetainfoError::InvalidField(
"pieces root not in piece layers for file larger than piece length",
));
}
if let Some(layer_hashes) = piece_layers.layers.get(root) {
let expected_pieces = flat_file.length.div_ceil(piece_length) as usize;
if layer_hashes.len() != expected_pieces {
return Err(MetainfoError::InvalidField(
"piece layers hash count mismatch",
));
}
}
} else {
return Err(MetainfoError::MissingField(
"pieces root for non-empty file",
));
}
}
files.push(File {
path,
length: flat_file.length,
offset,
pieces_root: flat_file.pieces_root,
attr: flat_file.attr,
});
total_length += flat_file.length;
if flat_file.length > 0 {
let pieces_for_file = flat_file.length.div_ceil(piece_length);
offset += pieces_for_file * piece_length;
}
}
Ok(Info {
name,
piece_length,
pieces: PieceHashes::V2(piece_layers),
files,
total_length,
private,
meta_version,
})
}
fn parse_info_hybrid(value: &Value, piece_layers: PieceLayers) -> Result<Info, MetainfoError> {
let dict = value.as_dict().ok_or(MetainfoError::InvalidField("info"))?;
let name = dict
.get(b"name".as_slice())
.and_then(|v| v.as_str())
.ok_or(MetainfoError::MissingField("name"))?
.to_string();
validate_path_component(&name)?;
let piece_length = dict
.get(b"piece length".as_slice())
.and_then(|v| v.as_integer())
.ok_or(MetainfoError::MissingField("piece length"))? as u64;
validate_piece_length(piece_length)?;
let pieces_bytes = dict
.get(b"pieces".as_slice())
.and_then(|v| v.as_bytes())
.ok_or(MetainfoError::MissingField("pieces"))?;
if pieces_bytes.len() % 20 != 0 {
return Err(MetainfoError::InvalidField("pieces"));
}
let v1_pieces: Vec<[u8; 20]> = pieces_bytes
.chunks_exact(20)
.map(|chunk| {
let mut arr = [0u8; 20];
arr.copy_from_slice(chunk);
arr
})
.collect();
let private = dict
.get(b"private".as_slice())
.and_then(|v| v.as_integer())
.map(|v| v == 1)
.unwrap_or(false);
let file_tree_value = dict
.get(b"file tree".as_slice())
.ok_or(MetainfoError::MissingField("file tree"))?;
let file_tree = FileTree::from_bencode(file_tree_value)?;
let flattened = file_tree.flatten();
let mut files = Vec::new();
let mut offset = 0u64;
let mut total_length = 0u64;
for flat_file in flattened {
for component in flat_file.path.components() {
if let std::path::Component::Normal(s) = component {
if let Some(s) = s.to_str() {
validate_path_component(s)?;
}
}
}
let path = PathBuf::from(&name).join(&flat_file.path);
files.push(File {
path,
length: flat_file.length,
offset,
pieces_root: flat_file.pieces_root,
attr: flat_file.attr,
});
total_length += flat_file.length;
offset += flat_file.length; }
let expected_v1_pieces = if total_length == 0 {
0
} else {
total_length.div_ceil(piece_length) as usize
};
if v1_pieces.len() != expected_v1_pieces {
return Err(MetainfoError::InvalidField(
"hybrid: v1 piece count doesn't match total length",
));
}
let _v2_piece_count: u64 = files
.iter()
.filter(|f| !f.is_padding() && f.length > 0)
.map(|f| f.length.div_ceil(piece_length))
.sum();
Ok(Info {
name,
piece_length,
pieces: PieceHashes::Hybrid {
v1: v1_pieces,
v2: piece_layers,
},
files,
total_length,
private,
meta_version: Some(2),
})
}
fn compute_info_hash(raw_info: &[u8]) -> InfoHash {
let mut hasher = Sha1::new();
hasher.update(raw_info);
let hash: [u8; 20] = hasher.finalize().into();
InfoHash::V1(hash)
}
fn compute_v2_info_hash(raw_info: &[u8]) -> InfoHash {
let mut hasher = Sha256::new();
hasher.update(raw_info);
let hash: [u8; 32] = hasher.finalize().into();
InfoHash::V2(hash)
}