use std::{ collections::BTreeMap, fs, sync::Mutex, time::{SystemTime, UNIX_EPOCH}, }; use aria2_rust_pro_core::{ BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtRuntimeState, DownloadId, PieceId, PieceState, RuntimeConfig, }; use aria2_rust_pro_protocol::{ DhtMessageModel, DhtNodeModel, DhtTransport, TrackerRequestModel, TrackerResponseModel, TrackerScrapeModel, TrackerTransport, torrent::{ DhtGetPeersQueryModel, DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind, PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel, TorrentMessageModel, parse_torrent_metadata, }, transport::{ PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse, TransportEndpoint, TransportError, TransportErrorKind, TransportScheme, }, }; use aria2_rust_pro_storage::load_session_file; use base64::Engine; use super::{ BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepStatus, InProcessRpcDispatcher, bt_metadata_piece_span, decode_hex_string_exact, }; use crate::{ jsonrpc::{JsonRpcRequest, jsonrpc_request_from_json, jsonrpc_response_to_json}, methods::RpcMethod, model::{RpcError, RpcMeta, RpcValue}, xmlrpc::{XmlRpcMember, XmlRpcMethodCall, XmlRpcParam, XmlRpcValue}, }; #[doc(hidden)] fn temp_session_path(name: &str) -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be monotonic enough for test naming") .as_nanos(); let root = std::env::temp_dir().join(format!( "aria2-rust-pro-rpc-test-{}-{nanos}", std::process::id() )); fs::create_dir_all(&root).expect("temp dir should be creatable"); root.join(name) } #[doc(hidden)] fn request(method: RpcMethod, params: Vec) -> JsonRpcRequest { JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: method.as_str().to_owned(), params, meta: RpcMeta::default(), } } #[doc(hidden)] fn request_with_method_name(method: &str, params: Vec) -> JsonRpcRequest { JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: method.to_owned(), params, meta: RpcMeta::default(), } } #[doc(hidden)] fn add_uri(dispatcher: &mut InProcessRpcDispatcher, uri: &str) -> String { let response = dispatcher.dispatch_json(request( RpcMethod::Aria2AddUri, vec![RpcValue::String(uri.to_owned())], )); match response.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addUri result: {other:?}"), } } #[doc(hidden)] #[test] fn add_uri_direct_registers_uri_and_options_without_jsonrpc_roundtrip() { let mut dispatcher = InProcessRpcDispatcher::new(); let gid = dispatcher .add_uri_direct( vec![ "https://example.org/direct-a.iso".to_owned(), "https://example.org/direct-b.iso".to_owned(), ], vec![("split".to_owned(), RpcValue::String("8".to_owned()))], ) .expect("direct addUri should register"); let group = dispatcher .engine .handle_mut(download_id(&gid)) .expect("direct addUri group should exist"); assert_eq!(group.uri(), "https://example.org/direct-a.iso"); assert_eq!( group.uris(), &[ "https://example.org/direct-a.iso".to_owned(), "https://example.org/direct-b.iso".to_owned(), ] ); assert_eq!(group.option_limit("split"), Some(8)); } #[doc(hidden)] fn download_id(gid: &str) -> DownloadId { DownloadId::parse_hex(gid).expect("gid should parse into DownloadId") } #[doc(hidden)] fn compact_peer(ip: [u8; 4], port: u16) -> Vec { let mut bytes = Vec::with_capacity(6); bytes.extend_from_slice(&ip); bytes.extend_from_slice(&port.to_be_bytes()); bytes } #[doc(hidden)] fn compact_node(node_id_byte: u8, ip: [u8; 4], port: u16) -> Vec { let mut bytes = vec![node_id_byte; 20]; bytes.extend_from_slice(&ip); bytes.extend_from_slice(&port.to_be_bytes()); bytes } #[doc(hidden)] #[derive(Debug)] struct FakeDhtTransport { #[doc(hidden)] response: DhtMessageModel, #[doc(hidden)] seen: Mutex>, } impl FakeDhtTransport { #[doc(hidden)] fn new(response: DhtMessageModel) -> Self { Self { response, seen: Mutex::new(Vec::new()), } } #[doc(hidden)] fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> { self.seen .lock() .expect("seen requests mutex should not be poisoned") .clone() } } impl DhtTransport for FakeDhtTransport { #[doc(hidden)] fn send_message( &self, node: &DhtNodeModel, message: &DhtMessageModel, ) -> Result { self.seen .lock() .expect("seen requests mutex should not be poisoned") .push((node.clone(), message.clone())); Ok(self.response.clone()) } } #[doc(hidden)] #[derive(Debug)] struct FakeTrackerTransport { #[doc(hidden)] announce_response: TrackerResponseModel, #[doc(hidden)] scrape_response: Option, #[doc(hidden)] seen_announces: Mutex>, #[doc(hidden)] seen_scrapes: Mutex>, } impl FakeTrackerTransport { #[doc(hidden)] fn new( announce_response: TrackerResponseModel, scrape_response: Option, ) -> Self { Self { announce_response, scrape_response, seen_announces: Mutex::new(Vec::new()), seen_scrapes: Mutex::new(Vec::new()), } } #[doc(hidden)] fn seen_announces(&self) -> Vec { self.seen_announces .lock() .expect("tracker announce mutex should not be poisoned") .clone() } #[doc(hidden)] fn seen_scrapes(&self) -> Vec { self.seen_scrapes .lock() .expect("tracker scrape mutex should not be poisoned") .clone() } } impl TrackerTransport for FakeTrackerTransport { #[doc(hidden)] fn announce( &self, request: &TrackerRequestModel, ) -> Result { self.seen_announces .lock() .expect("tracker announce mutex should not be poisoned") .push(request.clone()); Ok(self.announce_response.clone()) } #[doc(hidden)] fn scrape(&self, url: &str) -> Result { self.seen_scrapes .lock() .expect("tracker scrape mutex should not be poisoned") .push(url.to_owned()); self.scrape_response.clone().ok_or_else(|| TransportError { kind: TransportErrorKind::ProtocolViolation, message: "scrape unavailable".to_owned(), source: None, context: None, }) } } #[doc(hidden)] #[derive(Debug)] struct FakePeerWireConnector { #[doc(hidden)] response_payload: Vec, #[doc(hidden)] seen: Mutex>, } impl FakePeerWireConnector { #[doc(hidden)] fn new(response_payload: Vec) -> Self { Self { response_payload, seen: Mutex::new(Vec::new()), } } #[doc(hidden)] fn seen(&self) -> Vec { self.seen .lock() .expect("peer-wire seen requests mutex should not be poisoned") .clone() } } #[doc(hidden)] #[derive(Debug)] struct SequencedPeerWireConnector { #[doc(hidden)] response_payloads: Mutex>>, #[doc(hidden)] seen: Mutex>, } impl SequencedPeerWireConnector { #[doc(hidden)] fn new(response_payloads: Vec>) -> Self { Self { response_payloads: Mutex::new(response_payloads), seen: Mutex::new(Vec::new()), } } #[doc(hidden)] fn seen(&self) -> Vec { self.seen .lock() .expect("sequenced peer-wire seen requests mutex should not be poisoned") .clone() } } impl PeerWireTransportConnector for SequencedPeerWireConnector { #[doc(hidden)] fn connect_peer_wire( &self, request: &PeerWireTransportRequest, ) -> Result { self.seen .lock() .expect("sequenced peer-wire seen requests mutex should not be poisoned") .push(request.clone()); let payload = self .response_payloads .lock() .expect("sequenced peer-wire payload mutex should not be poisoned") .remove(0); Ok(PeerWireTransportResponse { endpoint: TransportEndpoint { scheme: TransportScheme::BitTorrent, address: request.endpoint.address.clone(), }, payload, }) } } impl PeerWireTransportConnector for FakePeerWireConnector { #[doc(hidden)] fn connect_peer_wire( &self, request: &PeerWireTransportRequest, ) -> Result { self.seen .lock() .expect("peer-wire seen requests mutex should not be poisoned") .push(request.clone()); Ok(PeerWireTransportResponse { endpoint: TransportEndpoint { scheme: TransportScheme::BitTorrent, address: request.endpoint.address.clone(), }, payload: self.response_payload.clone(), }) } } #[doc(hidden)] #[derive(Debug)] struct RoutedDhtTransport { #[doc(hidden)] get_peers_response: DhtMessageModel, #[doc(hidden)] announce_peer_response: DhtMessageModel, #[doc(hidden)] find_node_response: Option, #[doc(hidden)] ping_response: Option, #[doc(hidden)] seen: Mutex>, } impl RoutedDhtTransport { #[doc(hidden)] fn new(get_peers_response: DhtMessageModel, announce_peer_response: DhtMessageModel) -> Self { Self { get_peers_response, announce_peer_response, find_node_response: None, ping_response: None, seen: Mutex::new(Vec::new()), } } #[doc(hidden)] fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> { self.seen .lock() .expect("routed dht seen mutex should not be poisoned") .clone() } } impl DhtTransport for RoutedDhtTransport { #[doc(hidden)] fn send_message( &self, node: &DhtNodeModel, message: &DhtMessageModel, ) -> Result { self.seen .lock() .expect("routed dht seen mutex should not be poisoned") .push((node.clone(), message.clone())); match &message.body { DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => { Ok(self.get_peers_response.clone()) } DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => { Ok(self.announce_peer_response.clone()) } DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => self .find_node_response .clone() .ok_or_else(|| TransportError { kind: TransportErrorKind::ProtocolViolation, message: "find_node unavailable".to_owned(), source: None, context: None, }), DhtMessageBody::Query(DhtQueryModel::Ping(_)) => { self.ping_response.clone().ok_or_else(|| TransportError { kind: TransportErrorKind::ProtocolViolation, message: "ping unavailable".to_owned(), source: None, context: None, }) } _ => Err(TransportError { kind: TransportErrorKind::ProtocolViolation, message: "unexpected dht method for routed transport".to_owned(), source: None, context: None, }), } } } #[doc(hidden)] fn peer_wire_handshake_and_frames( info_hash: [u8; 20], peer_id: [u8; 20], frames: &[PeerWireMessageKind], ) -> Vec { let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize(); for frame in frames { bytes.extend_from_slice( &TorrentMessageModel::from_peer_wire_kind(frame.clone()) .serialize_peer_wire_frame() .expect("peer-wire frame should serialize"), ); } bytes } #[doc(hidden)] fn peer_from_ip(ip: &str, port: u16) -> BtPeerInfo { BtPeerInfo { peer_id: None, ip: ip.to_owned(), port, client_name: None, interested: false, choked: true, download_speed: 0, upload_speed: 0, seeder: false, } } #[doc(hidden)] fn single_file_torrent_bytes(name: &str, comment_len: usize) -> Vec { let comment = "x".repeat(comment_len); format!( "d8:announce35:http://tracker.example.org/announce7:comment{}:{}4:infod6:lengthi2048e4:name{}:{}12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee", comment.len(), comment, name.len(), name ) .into_bytes() } #[doc(hidden)] #[test] fn bt_runtime_coordinator_snapshot_surfaces_partial_magnet_runtime_and_recommended_actions() { let mut dispatcher = InProcessRpcDispatcher::new(); let gid = add_uri( &mut dispatcher, "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", ); let download_id = download_id(&gid); { let group = dispatcher .engine .handle_mut(download_id) .expect("download group should exist"); group.set_piece_length(1_024); group.set_total_length(4_096); group.set_piece_state(PieceId(0), PieceState::Pending); group.set_piece_state(PieceId(1), PieceState::Missing); group.set_dht_token(Some(b"cached-token".to_vec())); let bt = group.bt_mut().expect("bt runtime state should exist"); bt.dht_nodes = vec!["bad-node".to_owned(), "127.0.0.9:6881".to_owned()]; bt.peers = vec![peer_from_ip("127.0.0.7", 51413)]; } let snapshot = dispatcher .bt_runtime_coordinator_snapshot(&gid) .expect("snapshot should inspect bt runtime"); assert!(snapshot.metadata_only); assert!(snapshot.metadata_exchange_pending); assert_eq!(snapshot.tracker_count, 1); assert_eq!(snapshot.dht_node_count, 2); assert_eq!(snapshot.addressable_dht_node_count, 1); assert_eq!(snapshot.peer_count, 1); assert_eq!(snapshot.connectable_peer_count, 1); assert_eq!(snapshot.requestable_piece_count, 2); assert_eq!( snapshot.recommended_actions, vec![ BtRuntimeCoordinatorAction::TrackerAnnounce, BtRuntimeCoordinatorAction::DhtGetPeers, BtRuntimeCoordinatorAction::DhtAnnouncePeer, BtRuntimeCoordinatorAction::PeerWireExchange, ] ); } #[doc(hidden)] #[test] fn drive_bt_runtime_once_executes_newly_unlocked_bt_steps_within_one_iteration() { let mut dispatcher = InProcessRpcDispatcher::new(); let gid = add_uri( &mut dispatcher, "magnet:?xt=urn:btih:fedcba9876543210fedcba9876543210fedcba98&tr=http%3A%2F%2Ftracker.example.org%2Fannounce", ); let download_id = download_id(&gid); { let group = dispatcher .engine .handle_mut(download_id) .expect("download group should exist"); group.set_piece_length(1_024); group.set_total_length(2_048); group.set_piece_state(PieceId(0), PieceState::Pending); group.set_piece_state(PieceId(1), PieceState::Missing); let bt = group.bt_mut().expect("bt runtime state should exist"); bt.dht_nodes = vec!["127.0.0.11:6881".to_owned()]; } let tracker = FakeTrackerTransport::new( TrackerResponseModel { peers: aria2_rust_pro_protocol::TrackerPeerListModel { interval_sec: 1_800, peers: vec![aria2_rust_pro_protocol::torrent::TorrentPeerModel { ip: "127.0.0.21".to_owned(), port: 51_413, peer_id: Some(*b"12345678901234567890"), client_name: Some("tracker-peer".to_owned()), interested: false, choked: false, }], min_interval_sec: None, tracker_id: Some("tracker-id".to_owned()), }, scrape: None, }, Some(TrackerScrapeModel { complete: Some(5), downloaded: Some(8), incomplete: Some(3), files: Vec::new(), }), ); let dht = RoutedDhtTransport::new( DhtMessageModel::get_peers_response( b"gp".to_vec(), vec![0x99; 20], Some(b"announce-token".to_vec()), Some(compact_node(0x77, [127, 0, 0, 31], 6882)), Vec::new(), ), DhtMessageModel::ping_response(b"ap".to_vec(), vec![0x55; 20]), ); let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames( [ 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, ], *b"-PC0001-LOOP-PEER-01", &[PeerWireMessageKind::Unchoke], )); let report = dispatcher .drive_bt_runtime_once( &gid, Some(&tracker), Some(&dht), Some(&connector), Some(1_050), ) .expect("coordinator loop should run"); assert!(report.initial_snapshot.metadata_exchange_pending); assert!(report.final_snapshot.metadata_exchange_pending); assert_eq!( report .steps .iter() .map(|step| (step.action, step.status)) .collect::>(), vec![ ( BtRuntimeCoordinatorAction::AdvanceClock, BtRuntimeCoordinatorStepStatus::Executed, ), ( BtRuntimeCoordinatorAction::TrackerAnnounce, BtRuntimeCoordinatorStepStatus::Executed, ), ( BtRuntimeCoordinatorAction::DhtGetPeers, BtRuntimeCoordinatorStepStatus::Executed, ), ( BtRuntimeCoordinatorAction::DhtAnnouncePeer, BtRuntimeCoordinatorStepStatus::Executed, ), ( BtRuntimeCoordinatorAction::PeerWireExchange, BtRuntimeCoordinatorStepStatus::Executed, ), ] ); assert_eq!(tracker.seen_announces().len(), 1); assert_eq!( tracker.seen_scrapes(), vec!["http://tracker.example.org/announce".to_owned()] ); assert_eq!( dht.seen().len(), 2, "get_peers plus announce_peer should run" ); assert_eq!( connector.seen().len(), 1, "peer-wire should run after peers arrive" ); let final_snapshot = dispatcher .bt_runtime_coordinator_snapshot(&gid) .expect("final snapshot should remain readable"); assert!(final_snapshot.has_dht_token); assert!(final_snapshot.connectable_peer_count >= 1); assert!(final_snapshot.addressable_dht_node_count >= 2); } #[doc(hidden)] fn xml_request(method_name: &str) -> XmlRpcMethodCall { XmlRpcMethodCall { method_name: method_name.to_owned(), params: Vec::new(), meta: RpcMeta::default(), } } #[doc(hidden)] fn xml_request_with_params(method_name: &str, params: Vec) -> XmlRpcMethodCall { XmlRpcMethodCall { method_name: method_name.to_owned(), params: params .into_iter() .map(|value| XmlRpcParam { value }) .collect(), meta: RpcMeta::default(), } } mod bt_and_extension; mod protocol_surface; mod queue_and_options;