chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:16:57 +08:00
commit 17688c3e34
321 changed files with 76859 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
#![forbid(unsafe_code)]
#![doc = "Workspace integration and benchmark tests for aria2-rust-pro."]
#[cfg(test)]
use aria2_rust_pro_cli as _;
#[cfg(test)]
use criterion as _;
#[cfg(test)]
#[expect(
clippy::arithmetic_side_effects,
clippy::cognitive_complexity,
clippy::default_trait_access,
clippy::indexing_slicing,
clippy::integer_division,
clippy::too_many_lines,
reason = "integration tests keep protocol/RPC fixtures explicit so regressions remain auditable"
)]
mod tests {
use support::*;
#[path = "bt_status_and_selection.rs"]
mod bt_status_and_selection;
#[path = "dht_and_peer_wire.rs"]
mod dht_and_peer_wire;
#[path = "foundations_and_protocol.rs"]
mod foundations_and_protocol;
#[path = "rpc_parity.rs"]
mod rpc_parity;
#[path = "rpc_pressure_and_runtime.rs"]
mod rpc_pressure_and_runtime;
#[path = "support.rs"]
mod support;
#[path = "tracker_and_surface_regression.rs"]
mod tracker_and_surface_regression;
}
@@ -0,0 +1,701 @@
use super::*;
#[test]
fn add_torrent_registers_runtime_backed_bt_metadata_surfaces() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(false)));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:")
));
}
other => panic!("unexpected tellStatus after addTorrent: {other:?}"),
}
let files = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetFiles.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("path"),
Some(&RpcValue::String("ubuntu.iso".to_owned()))
);
assert_eq!(
file.get("length"),
Some(&RpcValue::String("32768".to_owned()))
);
}
other => panic!("unexpected getFiles entry after addTorrent: {other:?}"),
},
other => panic!("unexpected getFiles result after addTorrent: {other:?}"),
}
let servers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetServers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
#[test]
fn add_uri_magnet_registers_bt_runtime_status_surfaces() {
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-magnet.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true)));
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:")
));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
}
other => panic!("unexpected tellStatus after addUri magnet: {other:?}"),
}
}
#[test]
fn tracker_announce_ingestion_populates_peers_and_preserves_bt_servers() {
fn bencode_int(value: i64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut out = format!("{}:", value.len()).into_bytes();
out.extend_from_slice(value);
out
}
fn bencode_list(values: Vec<Vec<u8>>) -> Vec<u8> {
let mut out = vec![b'l'];
for value in values {
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_dict(entries: Vec<(&str, Vec<u8>)>) -> Vec<u8> {
let mut out = vec![b'd'];
for (key, value) in entries {
out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes());
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
let magnet = "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=bt-peers.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let announce_bytes = bencode_dict(vec![
("interval", bencode_int(1800)),
("tracker id", bencode_bytes(b"tracker-session-1")),
(
"peers",
bencode_list(vec![
bencode_dict(vec![
("ip", bencode_bytes(b"203.0.113.10")),
("port", bencode_int(51413)),
("peer id", bencode_bytes(b"-AZ2060-123456789012")),
("client", bencode_bytes(b"Azureus 2.0.6.0")),
("choked", bencode_int(0)),
("interested", bencode_int(1)),
]),
bencode_dict(vec![
("ip", bencode_bytes(b"203.0.113.11")),
("port", bencode_int(51414)),
("choked", bencode_int(1)),
("interested", bencode_int(0)),
]),
]),
),
]);
let announce = TrackerResponseModel::from_announce_bytes(&announce_bytes)
.expect("announce response should parse");
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker announce should ingest");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(entries)) => {
assert!(!entries.is_empty(), "expected concrete peer entries");
match entries.first() {
Some(RpcValue::Object(peer)) => {
assert!(matches!(peer.get("ip"), Some(RpcValue::String(_))));
assert!(matches!(peer.get("port"), Some(RpcValue::String(_))));
}
other => panic!("unexpected getPeers entry: {other:?}"),
}
}
other => panic!("unexpected getPeers result: {other:?}"),
}
let servers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetServers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
#[test]
fn live_http_tracker_announce_round_trips_into_dispatcher_peer_visibility() {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let payload = {
let mut payload = b"d8:intervali900e5:peers6:".to_vec();
payload.extend_from_slice(&[127, 0, 0, 1, 0x1A, 0xE1]);
payload.extend_from_slice(b"e");
payload
};
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("tracker client should connect");
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).expect("request should read");
let request_text = String::from_utf8_lossy(&request[..read]);
assert!(request_text.starts_with("GET /announce?"));
assert!(request_text.contains("compact=1"));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
payload.len()
);
stream
.write_all(response.as_bytes())
.expect("headers should write");
stream.write_all(&payload).expect("payload should write");
});
let transport = ReqwestTrackerTransport::new().expect("reqwest tracker transport should build");
let announce = transport
.announce(&TrackerRequestModel {
announce_url: format!("http://{addr}/announce"),
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(),
port: 6881,
uploaded: 0,
downloaded: 0,
left: 2048,
event: Some("started".to_owned()),
compact: true,
numwant: Some(10),
})
.expect("live announce should succeed");
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-live-tracker.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker announce should ingest");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("127.0.0.1".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned())));
}
other => panic!("unexpected peer payload after live tracker announce: {other:?}"),
},
other => panic!("unexpected getPeers result after live tracker announce: {other:?}"),
}
handle.join().expect("tracker server thread should join");
}
#[test]
fn bt_change_option_select_file_round_trips_through_get_files_selected_flags() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let change = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String(String::new()),
)])),
],
meta: Default::default(),
});
assert!(
change.error.is_some(),
"empty select-file value should be rejected to protect BT file selection contract"
);
let change = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
meta: Default::default(),
});
assert_eq!(change.result, Some(RpcValue::String("OK".to_owned())));
let files = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetFiles.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match files.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 1, "fixture torrent currently has one file");
match entries.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("selected"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected getFiles entry: {other:?}"),
}
}
other => panic!("unexpected getFiles response: {other:?}"),
}
}
#[test]
fn bt_pause_and_selected_state_are_persisted_in_saved_session_file() {
let session_root = std::env::temp_dir().join(format!(
"aria2-rust-pro-tests-bt-session-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&session_root);
let session_path = session_root.join("session.txt");
let mut dispatcher = InProcessRpcDispatcher::with_runtime(
RuntimeConfig::default().with_session_path(session_path.clone()),
);
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let _ = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
meta: Default::default(),
});
let pause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Pause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert_eq!(pause.result, Some(RpcValue::String(gid.clone())));
let save = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2SaveSession.as_str().to_owned(),
params: vec![],
meta: Default::default(),
});
assert_eq!(save.result, Some(RpcValue::String("OK".to_owned())));
let loaded = load_session_file(&session_path).expect("saved session file should load");
let persisted = loaded
.entries
.iter()
.find(|entry| entry.gid == gid)
.expect("saved session should include paused BT gid");
let metadata = persisted
.metadata
.as_ref()
.expect("session entry should include metadata");
assert_eq!(metadata.get("status"), Some(&"paused".to_owned()));
assert_eq!(
metadata.get("bt.file.0.selected"),
Some(&"true".to_owned()),
"session metadata should preserve BT selected-file flag for reload path"
);
let _ = std::fs::remove_file(session_path);
let _ = std::fs::remove_dir_all(session_root);
}
#[test]
fn bt_metadata_only_must_not_report_false_completion_lengths() {
let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-metadata-only.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true)));
assert_ne!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned())),
"metadata-only BT should not look fully complete before payload download"
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("0".to_owned())),
"metadata-only BT should not fake payload completed length"
);
}
other => panic!("unexpected tellStatus payload: {other:?}"),
}
}
#[test]
fn bt_select_file_pause_resume_and_save_session_contract_is_stable() {
let session_root = std::env::temp_dir().join(format!(
"aria2-rust-pro-tests-bt-select-pause-resume-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&session_root);
let session_path = session_root.join("session.txt");
let mut dispatcher = InProcessRpcDispatcher::with_runtime(
RuntimeConfig::default().with_session_path(session_path.clone()),
);
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let change = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
meta: Default::default(),
});
assert_eq!(change.result, Some(RpcValue::String("OK".to_owned())));
let pause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Pause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert!(pause.error.is_none());
let unpause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Unpause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert!(unpause.error.is_none());
let repause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Pause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert!(repause.error.is_none());
let files = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetFiles.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => assert_eq!(
file.get("selected"),
Some(&RpcValue::String("true".to_owned()))
),
other => panic!("unexpected getFiles entry after pause/resume: {other:?}"),
},
other => panic!("unexpected getFiles payload after pause/resume: {other:?}"),
}
let save = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2SaveSession.as_str().to_owned(),
params: vec![],
meta: Default::default(),
});
assert_eq!(save.result, Some(RpcValue::String("OK".to_owned())));
let loaded = load_session_file(&session_path).expect("saved session file should load");
let entry = loaded
.entries
.iter()
.find(|entry| entry.gid == gid)
.expect("saved session should include BT gid");
let metadata = entry
.metadata
.as_ref()
.expect("saved session entry should include metadata");
assert_eq!(metadata.get("status"), Some(&"paused".to_owned()));
assert_eq!(metadata.get("bt.file.0.selected"), Some(&"true".to_owned()));
let _ = std::fs::remove_file(session_path);
let _ = std::fs::remove_dir_all(session_root);
}
#[test]
fn bt_seeding_and_share_visibility_fields_exist_and_are_type_stable() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(
matches!(payload.get("status"), Some(RpcValue::String(_)),),
"status field should remain visible for BT"
);
assert!(
payload.contains_key("seeder"),
"BT status should expose seeder visibility field"
);
assert!(
payload.contains_key("seeders"),
"BT status should expose seeders visibility field"
);
assert!(
payload.contains_key("numSeeders"),
"BT status should expose numSeeders visibility field"
);
assert!(
payload.contains_key("shareRatio"),
"BT status should expose shareRatio visibility field"
);
assert!(
payload.contains_key("shareRatioProgress"),
"BT status should expose shareRatioProgress visibility field"
);
assert!(
payload.contains_key("shareRatioRemaining"),
"BT status should expose shareRatioRemaining visibility field"
);
assert!(
payload.contains_key("shareTime"),
"BT status should expose shareTime visibility field"
);
if let Some(value) = payload.get("shareRatio") {
assert!(
matches!(value, RpcValue::String(_) | RpcValue::Number(_)),
"shareRatio should stay scalar"
);
}
if let Some(value) = payload.get("shareTime") {
assert!(
matches!(value, RpcValue::String(_) | RpcValue::Number(_)),
"shareTime should stay scalar"
);
}
}
other => panic!("unexpected tellStatus payload for BT share visibility: {other:?}"),
}
}
@@ -0,0 +1,682 @@
use super::*;
#[test]
fn peer_wire_exchange_keeps_bt_peer_surface_visible_after_tracker_ingest() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let bootstrap_status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let info_hash = match bootstrap_status.result {
Some(RpcValue::Object(payload)) => match payload.get("magnetUri") {
Some(RpcValue::String(uri)) => info_hash_bytes(uri),
other => panic!("unexpected magnetUri payload after addTorrent: {other:?}"),
},
other => panic!("unexpected tellStatus bootstrap payload: {other:?}"),
};
dispatcher
.apply_tracker_announce_result(
&gid,
&TrackerResponseModel {
peers: TrackerPeerListModel {
interval_sec: 900,
peers: vec![TorrentPeerModel {
peer_id: None,
ip: "198.51.100.20".to_owned(),
port: 51413,
client_name: None,
interested: false,
choked: true,
}],
min_interval_sec: None,
tracker_id: Some("tracker-session-bt".to_owned()),
},
scrape: None,
},
)
.expect("tracker announce should seed a BT peer");
let connector = FixedPeerWireConnector {
response_payload: peer_wire_payload(
info_hash,
*b"-TR3000-RUNTIME-PEER",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[
true, true,
])),
PeerWireMessageKind::Have(1),
],
),
};
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("peerId"),
Some(&RpcValue::String(
"2d5452333030302d52554e54494d452d50454552".to_owned()
))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
peer.get("seeder"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected getPeers row after peer-wire exchange: {other:?}"),
},
other => panic!("unexpected getPeers result after peer-wire exchange: {other:?}"),
}
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("1".to_owned()))
);
}
other => panic!("unexpected tellStatus payload after peer-wire exchange: {other:?}"),
}
}
#[test]
fn execute_dht_get_peers_refreshes_peer_list_and_connection_count() {
let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-dht-exec.iso";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let transport = FixedDhtTransport {
response: DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x22; 20],
Some(b"dht-get-peers-token".to_vec()),
Some(compact_node(0x44, [203, 0, 113, 20], 6885)),
vec![
compact_peer([203, 0, 113, 10], 51413),
compact_peer([203, 0, 113, 11], 51414),
],
),
};
dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect("dht get_peers should succeed");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(
entries.len(),
2,
"dht peers should replace the visible peer list"
);
let ports = entries
.into_iter()
.filter_map(|entry| match entry {
RpcValue::Object(peer) => peer.get("port").cloned(),
_ => None,
})
.collect::<Vec<_>>();
assert!(ports.contains(&RpcValue::String("51413".to_owned())));
assert!(ports.contains(&RpcValue::String("51414".to_owned())));
}
other => panic!("unexpected getPeers result after dht execution: {other:?}"),
}
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("2".to_owned()))
);
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
}
other => panic!("unexpected tellStatus payload after dht execution: {other:?}"),
}
}
#[test]
fn dht_peer_wire_and_rpc_views_stay_in_sync_for_piece_progress_regression() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash"));
let dht_source = DhtNodeModel {
node_id: String::new(),
address: "203.0.113.200".to_owned(),
port: 6881,
};
let dht_response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x91; 20],
Some(b"dht-piece-token".to_vec()),
Some(compact_node(0x62, [203, 0, 113, 201], 6882)),
vec![compact_peer([198, 51, 100, 44], 51413)],
);
dispatcher
.apply_dht_get_peers_result(&gid, &dht_source, &dht_response)
.expect("dht peer discovery should seed a peer-wire target");
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
*b"-RTK0001-12345678901",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, false])),
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 0,
block_offset: 0,
block: vec![0xAB; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed after dht discovery");
let seen = connector.seen();
assert_eq!(seen.len(), 1, "peer-wire transport should see one exchange");
assert_eq!(seen[0].endpoint.address, "198.51.100.44:51413");
let (handshake, _consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload)
.expect("outbound peer-wire payload should begin with a handshake");
assert_eq!(handshake.info_hash, info_hash);
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
status.get("completedLength"),
Some(&RpcValue::String("16384".to_owned()))
);
assert_eq!(
status.get("completedPieces"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
status.get("bitfield"),
Some(&RpcValue::String("20".to_owned()))
);
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
let files = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
)));
match files.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("completedLength"),
Some(&RpcValue::String("16384".to_owned()))
);
assert_eq!(
file.get("bitfield"),
Some(&RpcValue::String("20".to_owned()))
);
}
other => {
panic!("unexpected getFiles payload after peer-wire piece exchange: {other:?}")
}
}
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid)],
)));
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.44".to_owned()))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
peer.get("downloadSpeed"),
Some(&RpcValue::String("16384".to_owned()))
);
}
other => {
panic!("unexpected getPeers payload after peer-wire piece exchange: {other:?}")
}
}
}
#[test]
fn tracker_scrape_then_dht_refresh_preserves_seed_counts_and_server_rows() {
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-handoff.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = match dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet.to_owned())],
))
.result
{
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let tracker_announce = TrackerResponseModel {
peers: TrackerPeerListModel {
interval_sec: 900,
peers: vec![TorrentPeerModel {
peer_id: None,
ip: "198.51.100.60".to_owned(),
port: 51413,
client_name: Some("tracker-peer".to_owned()),
interested: true,
choked: false,
}],
min_interval_sec: None,
tracker_id: Some("bt-tracker".to_owned()),
},
scrape: None,
};
dispatcher
.apply_tracker_announce_result(&gid, &tracker_announce)
.expect("tracker announce should populate peer and tracker views");
dispatcher
.apply_tracker_scrape_result(
&gid,
None,
&TrackerScrapeModel {
complete: Some(9),
downloaded: Some(12),
incomplete: Some(4),
files: vec![TrackerScrapeFileModel {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
complete: Some(9),
downloaded: Some(12),
incomplete: Some(4),
}],
},
)
.expect("tracker scrape should populate seeding counts");
let dht_response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x33; 20],
Some(b"handoff".to_vec()),
Some(compact_node(0x71, [203, 0, 113, 61], 6889)),
vec![compact_peer([203, 0, 113, 62], 6001)],
);
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.60".to_owned(),
port: 6881,
},
&dht_response,
)
.expect("dht refresh should replace the visible peer view");
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
status.get("numSeeders"),
Some(&RpcValue::String("9".to_owned()))
);
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert!(matches!(
status.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(
peers.len(),
1,
"dht snapshot should replace tracker peer rows"
);
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("203.0.113.62".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6001".to_owned())));
}
other => panic!("unexpected getPeers payload after tracker+dht handoff: {other:?}"),
}
let servers = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
#[test]
fn peer_wire_exchange_follows_dht_peer_refresh_under_swarm_churn() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash"));
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.70".to_owned(),
port: 6881,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x31; 20],
Some(b"churn-a".to_vec()),
None,
vec![compact_peer([198, 51, 100, 70], 51413)],
),
)
.expect("first dht refresh should seed peer A");
let connector_a = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
*b"-PC0001-CHURN-PEERA1",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 0,
block_offset: 0,
block: vec![0xAA; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector_a)
.expect("first peer-wire exchange should succeed");
let seen_a = connector_a.seen();
assert_eq!(seen_a.len(), 1, "peer A should receive one exchange");
assert_eq!(seen_a[0].endpoint.address, "198.51.100.70:51413");
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.71".to_owned(),
port: 6882,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x32; 20],
Some(b"churn-b".to_vec()),
None,
vec![compact_peer([198, 51, 100, 71], 51414)],
),
)
.expect("second dht refresh should replace visible peer with peer B");
let connector_b = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
*b"-PC0001-CHURN-PEERB1",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 1,
block_offset: 0,
block: vec![0xBB; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector_b)
.expect("second peer-wire exchange should follow refreshed peer");
let seen_b = connector_b.seen();
assert_eq!(seen_b.len(), 1, "peer B should receive one exchange");
assert_eq!(seen_b[0].endpoint.address, "198.51.100.71:51414");
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(
status.get("completedLength"),
Some(&RpcValue::String("32768".to_owned()))
);
assert_eq!(
status.get("completedPieces"),
Some(&RpcValue::String("2".to_owned()))
);
assert_eq!(
status.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
status.get("seeder"),
Some(&RpcValue::String("false".to_owned()))
);
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid)],
)));
assert_eq!(
peers.len(),
1,
"refreshed DHT peer view should replace stale peer A"
);
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.71".to_owned()))
);
assert_eq!(
peer.get("port"),
Some(&RpcValue::String("51414".to_owned()))
);
}
other => panic!("unexpected peer payload after swarm churn exchange: {other:?}"),
}
}
#[test]
fn dht_find_node_and_announce_peer_runtime_handoff_stays_coherent() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash"));
let find_node_transport = RecordingDhtTransport::new(DhtMessageModel::find_node_response(
b"fn".to_vec(),
vec![0x41; 20],
vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel {
node_id: [0x77; 20],
address: [203, 0, 113, 99],
port: 6891,
}],
));
dispatcher
.execute_dht_find_node(&gid, &find_node_transport)
.expect("dht find_node should succeed");
let find_node_seen = find_node_transport.seen();
assert_eq!(
find_node_seen.len(),
1,
"find_node should send exactly one query"
);
match &find_node_seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::FindNode(query)) => {
assert_eq!(query.target, info_hash);
}
other => panic!("unexpected find_node query payload: {other:?}"),
}
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.99".to_owned(),
port: 6891,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x55; 20],
Some(b"dht-announce-token".to_vec()),
None,
vec![compact_peer([198, 51, 100, 9], 51413)],
),
)
.expect("get_peers handoff should cache announce token");
let announce_transport = RecordingDhtTransport::new(DhtMessageModel::ping_response(
b"ap".to_vec(),
vec![0x66; 20],
));
dispatcher
.execute_dht_announce_peer(&gid, &announce_transport)
.expect("dht announce_peer should succeed after token handoff");
let announce_seen = announce_transport.seen();
assert_eq!(
announce_seen.len(),
1,
"announce_peer should send exactly one query"
);
match &announce_seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => {
assert_eq!(query.info_hash, info_hash);
assert_eq!(query.token, b"dht-announce-token".to_vec());
assert_eq!(query.port, 6881);
assert!(!query.implied_port);
}
other => panic!("unexpected announce_peer query payload: {other:?}"),
}
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
)));
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.9".to_owned()))
);
assert_eq!(
peer.get("port"),
Some(&RpcValue::String("51413".to_owned()))
);
}
other => panic!("unexpected peer payload after announce handoff: {other:?}"),
}
}
@@ -0,0 +1,284 @@
use super::*;
#[test]
fn phase_zero_workspace_tracks_goal_contract() {
assert_eq!(BASELINE_COMMIT, "1f1323128cae942f5440c035cb5f42788b3de33f");
assert!(is_required_pro_option("retry-on-403"));
assert!(is_required_protocol("xml-rpc"));
assert!(is_required_rpc_method("aria2.addMetalink"));
assert_eq!(Protocol::Https.as_str(), "https");
assert_eq!(ControlFileVersion::CURRENT.major(), 1);
assert_eq!(
GoalProgress::new("Phase 0 - Foundation").phase_name(),
"Phase 0 - Foundation"
);
}
#[test]
fn streamed_execution_truth_surfaces_align_across_storage_and_protocol() {
let payload = b"stream-truth";
let mut sink = ObservedByteSink::with_unbounded_retention();
sink.write(payload)
.expect("observed sink write is infallible");
let mut checksum = ChecksumSpec {
algorithm: "md5".to_owned(),
expected_hex: String::new(),
actual_hex: None,
};
checksum.expected_hex = checksum
.compute_actual_hex(sink.retained())
.expect("md5 digest should be computable");
let response = HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Streamed {
expected_len: None,
observed_len: Some(sink.observed_len()),
observed_digest: checksum.compute_actual_hex(sink.retained()),
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(checksum),
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(response.completed_length(), sink.observed_len());
assert_eq!(completion.completed_length, sink.observed_len());
assert!(completion.checksum_seen);
assert!(completion.checksum_verified);
assert_eq!(completion.state, HttpCompletionState::Verified);
}
#[test]
fn add_metalink_prefers_protocol_selected_resource_over_first_resource() {
let metalink_xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="metalink-priority.bin">
<url priority="10">http://fallback.example.org/metalink-priority.bin</url>
<url priority="1" location="CN" type="https">https://preferred.example.org/metalink-priority.bin</url>
</file>
</metalink>"#;
let parsed = parse_metalink_document(metalink_xml).expect("fixture metalink should parse");
let expected_uri = aria2_rust_pro_protocol::preferred_download_candidate(&parsed)
.map(|(_, resource)| resource.url.clone())
.expect("fixture should expose a preferred resource");
let mut dispatcher = InProcessRpcDispatcher::new();
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddMetalink.as_str().to_owned(),
params: vec![RpcValue::String(metalink_xml.to_owned())],
meta: Default::default(),
});
let gid = match add_response.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
let uris = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetUris.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
let selected_uri = match uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => match entry.get("uri") {
Some(RpcValue::String(uri)) => uri.clone(),
other => panic!("unexpected uri field payload: {other:?}"),
},
other => panic!("unexpected getUris first entry: {other:?}"),
},
other => panic!("unexpected getUris response: {other:?}"),
};
assert_eq!(
selected_uri, expected_uri,
"dispatcher should honor protocol-layer preferred resource selection"
);
}
#[test]
fn xmlrpc_add_metalink_roundtrip_and_dispatch_preserve_preferred_resource_semantics() {
let metalink_xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="metalink-xmlrpc.bin">
<url priority="20">http://fallback.example.org/metalink-xmlrpc.bin</url>
<url priority="1" type="https">https://preferred.example.org/metalink-xmlrpc.bin</url>
</file>
</metalink>"#;
let expected_uri = aria2_rust_pro_protocol::preferred_download_candidate(
&parse_metalink_document(metalink_xml).expect("fixture metalink should parse"),
)
.map(|(_, resource)| resource.url.clone())
.expect("fixture should expose a preferred resource");
let call_xml = format!(
"<methodCall><methodName>aria2.addMetalink</methodName><params><param><value><string>{}</string></value></param></params></methodCall>",
metalink_xml
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
);
let parsed_call = xmlrpc_method_call_from_xml(&call_xml).expect("xmlrpc methodCall parse");
let canonical_xml = xmlrpc_method_call_to_xml(&parsed_call);
let reparsed_call =
xmlrpc_method_call_from_xml(&canonical_xml).expect("canonical xmlrpc should parse");
assert!(
canonical_xml.contains("aria2.addMetalink")
&& canonical_xml.contains("metalink-xmlrpc.bin")
&& reparsed_call.method_name == "aria2.addMetalink",
"xmlrpc methodCall should roundtrip cleanly for raw transport"
);
let mut dispatcher = InProcessRpcDispatcher::new();
let version_response = dispatcher.dispatch_xml(
xmlrpc_method_call_from_xml(
"<methodCall><methodName>aria2.getVersion</methodName><params></params></methodCall>",
)
.expect("xmlrpc getVersion call should parse"),
);
let response_xml = xmlrpc_method_response_to_xml(&version_response);
assert!(
response_xml.contains("<methodResponse>")
&& response_xml.contains(aria2_rust_pro_compat::VERSION),
"xmlrpc methodResponse should be renderable for raw transport"
);
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddMetalink.as_str().to_owned(),
params: vec![RpcValue::String(metalink_xml.to_owned())],
meta: Default::default(),
});
let gid = match add_response.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
let uris = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetUris.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
let selected_uri = match uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => match entry.get("uri") {
Some(RpcValue::String(uri)) => uri.clone(),
other => panic!("unexpected uri field payload: {other:?}"),
},
other => panic!("unexpected getUris first entry: {other:?}"),
},
other => panic!("unexpected getUris response: {other:?}"),
};
assert_eq!(
selected_uri, expected_uri,
"protocol-layer preferred-resource selection should stay consistent across XML-RPC parse/render and JSON-RPC dispatch"
);
}
#[test]
fn xmlrpc_and_jsonrpc_add_metalink_expand_same_actionable_file_count() {
let metalink_xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="alpha.bin">
<url priority="1">https://example.org/alpha.bin</url>
</file>
<file name="ignored.bin">
<url priority="1"></url>
</file>
<file name="beta.bin">
<url priority="1">https://example.org/beta.bin</url>
</file>
</metalink>"#;
let mut json_dispatcher = InProcessRpcDispatcher::new();
let json_result = rpc_result(json_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddMetalink,
vec![RpcValue::String(metalink_xml.to_owned())],
)));
let mut xml_dispatcher = InProcessRpcDispatcher::new();
let xml_result = rpc_result_from_xml(xml_dispatcher.dispatch_xml(xmlrpc_request(
"aria2.addMetalink",
vec![RpcValue::String(metalink_xml.to_owned())],
)));
let json_count = match json_result {
RpcValue::Array(items) => items.len(),
other => panic!("unexpected json addMetalink payload: {other:?}"),
};
let xml_count = match xml_result {
RpcValue::Array(items) => items.len(),
other => panic!("unexpected xml addMetalink payload: {other:?}"),
};
assert_eq!(json_count, 2);
assert_eq!(xml_count, json_count);
}
#[test]
fn jsonrpc_save_session_writes_storage_compatible_session_file() {
let session_root = std::env::temp_dir().join(format!(
"aria2-rust-pro-tests-session-root-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&session_root);
let session_path = session_root.join("session.txt");
let mut dispatcher = InProcessRpcDispatcher::with_runtime(
RuntimeConfig::default().with_session_path(session_path.clone()),
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(
"https://session.example.org/session-download.bin".to_owned(),
)],
meta: Default::default(),
});
let added_gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let save = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2SaveSession.as_str().to_owned(),
params: vec![],
meta: Default::default(),
});
assert_eq!(
save.result,
Some(RpcValue::String("OK".to_owned())),
"saveSession should return OK for writable target"
);
let loaded = load_session_file(&session_path).expect("saved session file should load");
assert!(
loaded.entries.iter().any(|entry| entry.gid == added_gid
&& entry
.uris
.iter()
.any(|uri| uri.contains("session-download.bin"))),
"saved session should be readable by storage crate and contain addUri payload"
);
let _ = std::fs::remove_file(session_path);
let _ = std::fs::remove_dir_all(session_root);
}
@@ -0,0 +1,306 @@
use super::*;
#[test]
fn xmlrpc_bt_status_roundtrip_preserves_new_bt_runtime_fields() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let status_value = dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid)],
))
.result
.expect("tellStatus should produce a bt status payload");
let response_xml = xmlrpc_method_response_to_xml(&XmlRpcMethodResponse {
value: Some(rpc_value_to_xmlrpc(status_value)),
fault: None,
meta: Default::default(),
});
assert!(response_xml.contains("<name>isBt</name>"));
assert!(response_xml.contains("<name>announceList</name>"));
assert!(response_xml.contains("<name>bitfield</name>"));
assert!(response_xml.contains("<name>shareRatio</name>"));
assert!(response_xml.contains("<name>magnetUri</name>"));
let reparsed = xmlrpc_method_response_from_xml(&response_xml)
.expect("rendered xmlrpc response should roundtrip");
let rerendered = xmlrpc_method_response_to_xml(&reparsed);
assert!(rerendered.contains("<name>isBt</name>"));
assert!(rerendered.contains("<name>seeder</name>"));
assert!(rerendered.contains("<name>numSeeders</name>"));
}
#[test]
fn parsed_jsonrpc_change_option_request_shape_matches_manual_dispatch_state() {
let mut parsed_dispatcher = InProcessRpcDispatcher::new();
let mut manual_dispatcher = InProcessRpcDispatcher::new();
let parsed_gid = match parsed_dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(
"https://example.org/shape-parsed.iso".to_owned(),
)],
))
.result
{
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected parsed addUri result: {other:?}"),
};
let manual_gid = match manual_dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(
"https://example.org/shape-manual.iso".to_owned(),
)],
))
.result
{
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected manual addUri result: {other:?}"),
};
let request_json = format!(
r#"{{"jsonrpc":"2.0","id":"lane-f-shape","method":"aria2.changeOption","params":["{parsed_gid}",{{"split":8,"out":"shape.bin"}}]}}"#
);
let parsed_request =
jsonrpc_request_from_json(&request_json).expect("request JSON should parse");
let _ = parsed_dispatcher.dispatch_json(parsed_request);
let _ = manual_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(manual_gid.clone()),
RpcValue::Object(BTreeMap::from([
("out".to_owned(), RpcValue::String("shape.bin".to_owned())),
("split".to_owned(), RpcValue::Number(8)),
])),
],
));
let parsed_option_payload = rpc_object_result(parsed_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(parsed_gid)],
)));
let manual_option_payload = rpc_object_result(manual_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(manual_gid)],
)));
assert_eq!(
parsed_option_payload, manual_option_payload,
"raw JSON-RPC request parsing should preserve the same downstream option state as a manually constructed request"
);
}
#[test]
fn xmlrpc_and_jsonrpc_get_global_option_payloads_match_exactly() {
let mut dispatcher = InProcessRpcDispatcher::new();
let _ = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-connection-per-server".to_owned(),
RpcValue::String("32".to_owned()),
),
("retry-on-403".to_owned(), RpcValue::Bool(true)),
(
"all-proxy-user".to_owned(),
RpcValue::String("proxy-user".to_owned()),
),
("ftp-pasv".to_owned(), RpcValue::Bool(false)),
]))],
));
let json_payload = rpc_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2GetGlobalOption, Vec::new())),
);
let xml_payload = rpc_result_from_xml(
dispatcher.dispatch_xml(xmlrpc_request("aria2.getGlobalOption", Vec::new())),
);
assert_eq!(
xml_payload, json_payload,
"XML-RPC should expose the same global option object as JSON-RPC"
);
}
#[test]
fn xmlrpc_and_jsonrpc_get_option_payloads_match_for_proxy_and_ftp_surface() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = match dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::Array(vec![RpcValue::String(
"https://example.org/file.iso".to_owned(),
)])],
)) {
JsonRpcResponse {
result: Some(RpcValue::String(gid)),
..
} => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let _ = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"all-proxy-user".to_owned(),
RpcValue::String("global-proxy-user".to_owned()),
)]))],
));
let _ = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([
(
"ftp-proxy-user".to_owned(),
RpcValue::String("ftp-user".to_owned()),
),
("ftp-pasv".to_owned(), RpcValue::Bool(false)),
])),
],
));
let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
)));
let xml_payload = rpc_result_from_xml(dispatcher.dispatch_xml(xmlrpc_request(
"aria2.getOption",
vec![RpcValue::String(gid)],
)));
assert_eq!(
xml_payload, json_payload,
"XML-RPC should expose the same per-download option object as JSON-RPC for proxy/FTP options"
);
}
#[test]
fn xmlrpc_and_jsonrpc_tell_status_filtered_bt_payloads_match_after_runtime_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
dispatcher
.apply_bt_runtime_tick(&gid, 32_768, 0, 512, 0, 0, 0, false, Some(6))
.expect("download tick should complete the torrent payload");
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000))
.expect("completed torrent should enter seeding");
dispatcher
.tick_bt_runtime_clock(&gid, 1_045, true)
.expect("share clock should advance");
dispatcher
.apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(9))
.expect("upload tick should enrich bt runtime fields");
let selected_keys = vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
RpcValue::String("completedLength".to_owned()),
RpcValue::String("uploadLength".to_owned()),
RpcValue::String("connections".to_owned()),
RpcValue::String("files".to_owned()),
RpcValue::String("bitfield".to_owned()),
RpcValue::String("isBt".to_owned()),
RpcValue::String("metadataOnly".to_owned()),
RpcValue::String("magnetUri".to_owned()),
RpcValue::String("announceList".to_owned()),
RpcValue::String("shareRatio".to_owned()),
RpcValue::String("shareTime".to_owned()),
RpcValue::String("seeder".to_owned()),
RpcValue::String("numSeeders".to_owned()),
RpcValue::String("completedPieces".to_owned()),
];
let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![
RpcValue::String(gid.clone()),
RpcValue::Array(selected_keys.clone()),
],
)));
let xml_payload = rpc_result_from_xml(dispatcher.dispatch_xml(xmlrpc_request(
"aria2.tellStatus",
vec![RpcValue::String(gid), RpcValue::Array(selected_keys)],
)));
assert_eq!(
xml_payload, json_payload,
"filtered BT tellStatus payloads should stay semantically identical across XML-RPC and JSON-RPC"
);
}
#[test]
fn xmlrpc_and_jsonrpc_multicall_nested_payloads_match_semantically() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let method_specs = RpcValue::Array(vec![
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.getVersion".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
])),
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.getSessionInfo".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
])),
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.tellStatus".to_owned()),
),
(
"params".to_owned(),
RpcValue::Array(vec![
RpcValue::String(gid),
RpcValue::Array(vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
RpcValue::String("isBt".to_owned()),
RpcValue::String("magnetUri".to_owned()),
]),
]),
),
])),
]);
let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::SystemMulticall,
vec![method_specs.clone()],
)));
let xml_payload = rpc_result_from_xml(
dispatcher.dispatch_xml(xmlrpc_request("system.multicall", vec![method_specs])),
);
assert_eq!(
xml_payload, json_payload,
"nested multicall results should preserve the same payload structure across RPC front doors"
);
}
#[test]
fn xmlrpc_and_jsonrpc_invalid_gid_errors_share_underlying_rpc_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-option-gid".to_owned();
let json_error = dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
))
.error
.expect("invalid gid should return a JSON-RPC error");
let xml_fault = dispatcher
.dispatch_xml(xmlrpc_request(
"aria2.getOption",
vec![RpcValue::String(gid)],
))
.fault
.expect("invalid gid should return an XML-RPC fault");
assert_eq!(xml_fault.code, 1);
assert_eq!(xml_fault.message, json_error.message);
assert_eq!(xml_fault.error, Some(json_error));
}
@@ -0,0 +1,617 @@
use super::*;
#[test]
fn rpc_bt_status_pressure_smoke_keeps_responses_healthy() {
let mut dispatcher = InProcessRpcDispatcher::new();
let mut gids = Vec::new();
for i in 0..32 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bt-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 1
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result in pressure smoke: {other:?}"),
};
gids.push(gid);
}
let mut ok_responses = 0usize;
for _round in 0..4 {
for gid in &gids {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
ok_responses += 1;
}
other => panic!("unexpected tellStatus payload in pressure smoke: {other:?}"),
}
}
}
assert_eq!(ok_responses, gids.len() * 4);
}
#[test]
fn rpc_bt_pressure_smoke_keeps_true_seeding_fields_consistent() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())],
meta: Default::default(),
});
let torrent_gid = match torrent_add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result in pressure smoke: {other:?}"),
};
let mut background_gids = Vec::new();
for i in 0..24 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bt-live-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 101
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
match add.result {
Some(RpcValue::String(gid)) => background_gids.push(gid),
other => panic!("unexpected addUri result in live pressure smoke: {other:?}"),
}
}
let mut observed_share_times = Vec::new();
let mut torrent_seed_states = Vec::new();
for round in 0..4 {
for gid in std::iter::once(&torrent_gid).chain(background_gids.iter()) {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let payload = match status.result {
Some(RpcValue::Object(payload)) => payload,
other => {
panic!("unexpected tellStatus payload in live pressure smoke: {other:?}")
}
};
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
if gid == &torrent_gid {
let seeder = match payload.get("seeder") {
Some(RpcValue::String(value)) => value == "true",
other => {
panic!("unexpected seeder field in live pressure smoke: {other:?}")
}
};
torrent_seed_states.push(seeder);
let share_time = match payload.get("shareTime") {
Some(RpcValue::String(value)) => {
value.parse::<u64>().expect("shareTime should parse")
}
other => {
panic!("unexpected shareTime field in live pressure smoke: {other:?}")
}
};
observed_share_times.push(share_time);
if round >= 2 {
assert!(
seeder,
"torrent should be a local seeder once payload is complete"
);
assert_eq!(
payload.get("shareRatio"),
Some(&RpcValue::String("0.500".to_owned()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String("180".to_owned()))
);
} else if round == 0 {
assert!(
!seeder,
"torrent should not claim local seeding before any seeding runtime has started"
);
}
}
}
match round {
0 => {
dispatcher
.set_bt_seeding_state(&torrent_gid, true, Some(1_000))
.expect("should start seeding runtime state before completion");
dispatcher
.tick_bt_runtime_clock(&torrent_gid, 1_020, true)
.expect("should advance runtime clock before completion");
}
1 => {
dispatcher
.apply_bt_runtime_tick(
&torrent_gid,
32_768,
16_384,
90,
180,
5,
5,
true,
Some(16),
)
.expect("should complete payload and expose true seeding");
}
2 => {
dispatcher
.tick_bt_runtime_clock(&torrent_gid, 1_040, true)
.expect("should keep advancing share clock after completion");
}
_ => {}
}
}
assert_eq!(observed_share_times[0], 0);
assert!(torrent_seed_states[0..1].iter().all(|state| !state));
assert!(torrent_seed_states[2..].iter().all(|state| *state));
assert!(
observed_share_times
.windows(2)
.all(|window| window[1] >= window[0]),
"shareTime should be monotonic after seeding becomes visible: {observed_share_times:?}"
);
assert_eq!(observed_share_times[1], 20);
assert_eq!(observed_share_times[2], 25);
assert_eq!(observed_share_times[3], 45);
}
#[test]
fn rpc_bt_pressure_guard_keeps_status_active_and_global_stat_responsive() {
let mut dispatcher = InProcessRpcDispatcher::new();
let mut gids = Vec::new();
for i in 0..64 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 10_001
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
match add.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => panic!("unexpected addUri result in BT pressure guard: {other:?}"),
}
}
let mut tell_status_calls = 0usize;
let mut tell_status_millis = 0_u128;
let mut tell_active_millis = 0_u128;
let mut tell_global_stat_millis = 0_u128;
for round in 0..4 {
if let Some(first_gid) = gids.first() {
dispatcher
.apply_bt_runtime_tick(
first_gid,
0,
0,
200 + round * 10,
100 + round * 10,
0,
0,
false,
Some(8),
)
.expect("pressure guard runtime tick should succeed");
}
let tell_status_start = Instant::now();
for gid in &gids {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
tell_status_calls += 1;
}
other => {
panic!("unexpected tellStatus payload in BT pressure guard: {other:?}")
}
}
}
tell_status_millis += tell_status_start.elapsed().as_millis();
let tell_active_start = Instant::now();
let active = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellActive.as_str().to_owned(),
params: Vec::new(),
meta: Default::default(),
});
match active.result {
Some(RpcValue::Array(items)) => {
assert!(
items.iter().all(|item| matches!(item, RpcValue::Object(_))),
"tellActive should keep returning object rows under BT-like pressure"
);
}
other => panic!("unexpected tellActive payload in BT pressure guard: {other:?}"),
}
tell_active_millis += tell_active_start.elapsed().as_millis();
let tell_global_stat_start = Instant::now();
let global = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellGlobalStat.as_str().to_owned(),
params: Vec::new(),
meta: Default::default(),
});
match global.result {
Some(RpcValue::Object(payload)) => {
assert!(payload.contains_key("numActive"));
assert!(payload.contains_key("downloadSpeed"));
}
other => {
panic!("unexpected tellGlobalStat payload in BT pressure guard: {other:?}")
}
}
tell_global_stat_millis += tell_global_stat_start.elapsed().as_millis();
}
assert_eq!(tell_status_calls, gids.len() * 4);
assert!(
tell_status_millis <= 2_000,
"synthetic tellStatus pressure guard regressed badly: {tell_status_millis}ms for {tell_status_calls} calls"
);
assert!(
tell_active_millis <= 500,
"synthetic tellActive pressure guard regressed badly: {tell_active_millis}ms"
);
assert!(
tell_global_stat_millis <= 500,
"synthetic tellGlobalStat pressure guard regressed badly: {tell_global_stat_millis}ms"
);
}
#[test]
fn rpc_bt_mixed_pressure_guard_covers_churned_status_files_and_global_views() {
const TASK_COUNT: usize = 96;
const ROUNDS: usize = 6;
const FILE_SAMPLE_STRIDE: usize = 8;
let mut dispatcher = InProcessRpcDispatcher::new();
let mut gids = Vec::new();
for i in 0..TASK_COUNT {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=mixed-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 30_001
);
let add = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet)],
));
match add.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => {
panic!("unexpected addUri result in mixed BT pressure guard: {other:?}")
}
}
}
let mut tell_status_calls = 0usize;
let mut get_files_calls = 0usize;
let mut tell_active_calls = 0usize;
let mut tell_global_stat_calls = 0usize;
let mut mixed_millis = 0_u128;
for round in 0..ROUNDS {
let round_start = Instant::now();
for (index, gid) in gids.iter().enumerate() {
dispatcher
.apply_bt_runtime_tick(
gid,
u64::try_from(index + round).unwrap_or(u64::MAX),
u64::from(index % 3 == 0),
128 + u64::try_from(round).unwrap_or_default(),
64 + u64::try_from(index % 17).unwrap_or_default(),
u64::from(index % 5 == 0),
u64::from(index % 7 == 0),
index % 11 == 0,
Some(2 + u32::try_from(index % 9).unwrap_or_default()),
)
.expect("mixed pressure guard runtime tick should succeed");
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(status.contains_key("status"));
assert!(status.contains_key("completedLength"));
assert!(status.contains_key("connections"));
tell_status_calls += 1;
}
for gid in gids
.iter()
.skip(round % FILE_SAMPLE_STRIDE)
.step_by(FILE_SAMPLE_STRIDE)
{
let files = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
)));
match files.first() {
Some(RpcValue::Object(file)) => {
assert!(file.contains_key("selected"));
assert!(file.contains_key("completedLength"));
assert!(file.contains_key("bitfield"));
}
other => {
panic!("unexpected getFiles payload in mixed pressure guard: {other:?}")
}
}
get_files_calls += 1;
}
let active = rpc_array_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellActive, Vec::new())),
);
assert!(
active
.iter()
.all(|item| matches!(item, RpcValue::Object(_))),
"tellActive should keep object rows under mixed BT pressure"
);
tell_active_calls += 1;
let global = rpc_object_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())),
);
assert!(global.contains_key("numActive"));
assert!(global.contains_key("downloadSpeed"));
assert!(global.contains_key("uploadSpeed"));
tell_global_stat_calls += 1;
mixed_millis += round_start.elapsed().as_millis();
}
assert_eq!(tell_status_calls, TASK_COUNT * ROUNDS);
assert_eq!(get_files_calls, (TASK_COUNT / FILE_SAMPLE_STRIDE) * ROUNDS);
assert_eq!(tell_active_calls, ROUNDS);
assert_eq!(tell_global_stat_calls, ROUNDS);
assert!(
mixed_millis <= 4_000,
"synthetic mixed BT RPC pressure guard regressed badly: {mixed_millis}ms for {tell_status_calls} tellStatus, {get_files_calls} getFiles, {tell_active_calls} tellActive, and {tell_global_stat_calls} tellGlobalStat calls"
);
}
#[test]
fn shared_runtime_speed_caps_rebalance_after_one_download_completes() {
let mut dispatcher = InProcessRpcDispatcher::with_runtime(RuntimeConfig {
max_overall_download_limit: Some(1_200),
max_overall_upload_limit: Some(600),
..RuntimeConfig::default()
});
let gids = vec![
add_magnet(&mut dispatcher, 70_001),
add_magnet(&mut dispatcher, 70_002),
add_magnet(&mut dispatcher, 70_003),
];
let change_first = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gids[0].clone()),
RpcValue::Object(BTreeMap::from([
(
"max-download-limit".to_owned(),
RpcValue::String("250".to_owned()),
),
(
"max-upload-limit".to_owned(),
RpcValue::String("120".to_owned()),
),
])),
],
));
assert!(
change_first.error.is_none(),
"changeOption should succeed for the constrained fairness gid"
);
for gid in &gids {
dispatcher
.apply_bt_runtime_tick(gid, 128, 64, 5_000, 2_000, 0, 0, false, Some(6))
.expect("initial fairness runtime tick should succeed");
}
let initial_expected = [(250_u64, 120_u64), (400, 200), (400, 200)];
for (gid, (expected_download_speed, expected_upload_speed)) in gids.iter().zip(initial_expected)
{
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(
rpc_u64_field(&status, "downloadSpeed"),
expected_download_speed
);
assert_eq!(rpc_u64_field(&status, "uploadSpeed"), expected_upload_speed);
assert_eq!(rpc_u64_field(&status, "completedLength"), 128);
}
let initial_global = rpc_object_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())),
);
assert_eq!(rpc_u64_field(&initial_global, "downloadSpeed"), 1_050);
assert_eq!(rpc_u64_field(&initial_global, "uploadSpeed"), 520);
dispatcher
.mark_complete(&gids[0])
.expect("completing the constrained gid should succeed");
for gid in &gids[1..] {
dispatcher
.apply_bt_runtime_tick(gid, 256, 96, 5_000, 2_000, 0, 0, false, Some(6))
.expect("post-completion fairness runtime tick should succeed");
}
let completed_status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gids[0].clone())],
)));
assert_eq!(
completed_status.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
for gid in &gids[1..] {
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(rpc_u64_field(&status, "downloadSpeed"), 600);
assert_eq!(rpc_u64_field(&status, "uploadSpeed"), 300);
assert_eq!(rpc_u64_field(&status, "completedLength"), 384);
}
let rebalanced_global = rpc_object_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())),
);
assert_eq!(rpc_u64_field(&rebalanced_global, "downloadSpeed"), 1_200);
assert_eq!(rpc_u64_field(&rebalanced_global, "uploadSpeed"), 600);
}
#[test]
fn bt_live_pressure_status_regression_tracks_true_seeding_runtime_over_repeated_rpc_probes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(bootstrap.completed_length, 0);
assert_eq!(bootstrap.share_time, 0);
assert_eq!(bootstrap.share_ratio, "0.000");
assert!(!bootstrap.seeder);
dispatcher
.apply_bt_runtime_tick(&gid, 8_192, 0, 256, 0, 0, 0, false, Some(2))
.expect("first runtime tick should advance partial payload progress");
let partial_a = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(partial_a.completed_length, 8_192);
assert_eq!(partial_a.share_time, 0);
assert_eq!(partial_a.share_ratio, "0.000");
assert!(!partial_a.seeder);
assert_eq!(partial_a.connections, 2);
dispatcher
.apply_bt_runtime_tick(&gid, 8_192, 0, 256, 0, 0, 0, false, Some(3))
.expect("second runtime tick should continue partial payload progress");
let partial_b = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(partial_b.completed_length, 16_384);
assert_eq!(partial_b.share_time, 0);
assert_eq!(partial_b.share_ratio, "0.000");
assert!(!partial_b.seeder);
assert_eq!(partial_b.connections, 3);
dispatcher
.apply_bt_runtime_tick(&gid, 16_384, 0, 512, 0, 0, 0, false, Some(4))
.expect("final download tick should complete payload without forcing seeding");
let completed = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(completed.completed_length, 32_768);
assert_eq!(completed.share_time, 0);
assert_eq!(completed.share_ratio, "0.000");
assert!(
!completed.seeder,
"seeder must stay false until the payload is complete and seeding state flips"
);
assert_eq!(completed.connections, 4);
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000))
.expect("payload-complete bt group should enter seeding");
let seeding_started = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(seeding_started.completed_length, 32_768);
assert_eq!(seeding_started.share_time, 0);
assert_eq!(seeding_started.share_ratio, "0.000");
assert!(
seeding_started.seeder,
"seeder should only flip after payload completion once seeding begins"
);
dispatcher
.tick_bt_runtime_clock(&gid, 1_040, true)
.expect("share clock should advance under repeated rpc probing");
let seeded_40 = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(seeded_40.completed_length, 32_768);
assert_eq!(
seeded_40.share_time, 40,
"shareTime should reflect the live seeding runtime after 40 seconds"
);
assert_eq!(seeded_40.share_ratio, "0.000");
assert!(seeded_40.seeder);
dispatcher
.apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(16))
.expect("upload tick should populate live share ratio after true payload completion");
let seeded_45 = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(seeded_45.completed_length, 32_768);
assert_eq!(
seeded_45.share_time, 45,
"shareTime should remain monotonic as runtime ticks continue"
);
assert_eq!(
seeded_45.share_ratio, "0.500",
"shareRatio should become meaningful after true payload completion and upload runtime"
);
assert!(seeded_45.seeder);
assert_eq!(seeded_45.connections, 16);
let observed = [
bootstrap.share_time,
partial_a.share_time,
partial_b.share_time,
completed.share_time,
seeding_started.share_time,
seeded_40.share_time,
seeded_45.share_time,
];
assert!(
observed.windows(2).all(|window| window[0] <= window[1]),
"shareTime should stay monotonic across repeated rpc probes: {observed:?}"
);
}
@@ -0,0 +1,340 @@
pub(super) use std::{collections::BTreeMap, sync::Mutex, time::Instant};
pub(super) use aria2_rust_pro_compat::{
BASELINE_COMMIT, is_required_pro_option, is_required_protocol,
};
pub(super) use aria2_rust_pro_core::{GoalProgress, RuntimeConfig};
pub(super) use aria2_rust_pro_protocol::{
ChecksumSpec, DhtMessageModel, DhtNodeModel, DhtTransport, HttpCompletionState,
HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestTrackerTransport,
ResponseBody, TorrentPeerModel, TrackerPeerListModel, TrackerRequestModel,
TrackerResponseModel, TrackerScrapeFileModel, TrackerScrapeModel, TrackerTransport,
parse_metalink_document,
torrent::{
DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, PeerWireHandshakeModel,
PeerWireMessageKind, PeerWirePieceBlockModel, TorrentMessageModel,
},
transport::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
TransportEndpoint, TransportError, TransportScheme,
},
};
pub(super) use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse, RpcMethod, RpcValue, XmlRpcMethodCall,
XmlRpcMethodResponse, XmlRpcParam, is_required_rpc_method, jsonrpc_request_from_json,
rpc_value_to_xmlrpc, xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml,
xmlrpc_method_response_from_xml, xmlrpc_method_response_to_xml, xmlrpc_value_to_rpc,
};
pub(super) use aria2_rust_pro_storage::{
ByteSink, ControlFileVersion, ObservedByteSink, load_session_file,
};
pub(super) const BT_TORRENT_FIXTURE: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
pub(super) fn rpc_request(method: RpcMethod, params: Vec<RpcValue>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.as_str().to_owned(),
params,
meta: Default::default(),
}
}
pub(super) fn xmlrpc_request(method_name: &str, params: Vec<RpcValue>) -> XmlRpcMethodCall {
XmlRpcMethodCall {
method_name: method_name.to_owned(),
params: params
.into_iter()
.map(|value| XmlRpcParam {
value: rpc_value_to_xmlrpc(value),
})
.collect(),
meta: Default::default(),
}
}
pub(super) fn rpc_result(response: JsonRpcResponse) -> RpcValue {
match response.result {
Some(result) => result,
None => panic!("unexpected rpc result envelope without result: {response:?}"),
}
}
pub(super) fn rpc_result_from_xml(response: XmlRpcMethodResponse) -> RpcValue {
match response {
XmlRpcMethodResponse {
value: Some(value),
fault: None,
..
} => xmlrpc_value_to_rpc(value),
other => panic!("unexpected xmlrpc success envelope: {other:?}"),
}
}
pub(super) fn add_torrent(dispatcher: &mut InProcessRpcDispatcher) -> String {
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
}
}
pub(super) fn rpc_object_result(response: JsonRpcResponse) -> BTreeMap<String, RpcValue> {
match response.result {
Some(RpcValue::Object(payload)) => payload,
other => panic!("unexpected rpc object result: {other:?}"),
}
}
pub(super) fn rpc_array_result(response: JsonRpcResponse) -> Vec<RpcValue> {
match response.result {
Some(RpcValue::Array(entries)) => entries,
other => panic!("unexpected rpc array result: {other:?}"),
}
}
pub(super) fn rpc_string_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> String {
match payload.get(field) {
Some(RpcValue::String(value)) => value.clone(),
other => panic!("unexpected string field {field}: {other:?}"),
}
}
pub(super) fn rpc_u64_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> u64 {
match payload.get(field) {
Some(RpcValue::String(value)) => value
.parse()
.unwrap_or_else(|error| panic!("unexpected u64 string field {field}: {error}")),
Some(RpcValue::Number(value)) => (*value)
.try_into()
.unwrap_or_else(|_| panic!("unexpected negative number field {field}: {value}")),
other => panic!("unexpected u64 field {field}: {other:?}"),
}
}
pub(super) fn rpc_bool_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> bool {
match payload.get(field) {
Some(RpcValue::Bool(value)) => *value,
Some(RpcValue::String(value)) => value
.parse()
.unwrap_or_else(|error| panic!("unexpected bool string field {field}: {error}")),
other => panic!("unexpected bool field {field}: {other:?}"),
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(super) struct BtStatusProbe {
pub(super) completed_length: u64,
pub(super) share_time: u64,
pub(super) share_ratio: String,
pub(super) seeder: bool,
pub(super) connections: u64,
}
pub(super) fn bt_status_probe(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> BtStatusProbe {
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.to_owned())],
)));
BtStatusProbe {
completed_length: rpc_u64_field(&status, "completedLength"),
share_time: rpc_u64_field(&status, "shareTime"),
share_ratio: rpc_string_field(&status, "shareRatio"),
seeder: rpc_bool_field(&status, "seeder"),
connections: rpc_u64_field(&status, "connections"),
}
}
pub(super) fn add_magnet(dispatcher: &mut InProcessRpcDispatcher, suffix: u64) -> String {
let magnet = format!(
"magnet:?xt=urn:btih:{suffix:040x}&dn=fairness-{suffix}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"
);
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet)],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result in fairness setup: {other:?}"),
}
}
pub(super) fn decode_hex_20(raw: &str) -> [u8; 20] {
let bytes = (0..raw.len())
.step_by(2)
.map(|offset| u8::from_str_radix(&raw[offset..offset + 2], 16))
.collect::<Result<Vec<_>, _>>()
.expect("info hash should be valid hex");
bytes.try_into().expect("info hash should be 20 bytes")
}
pub(super) fn peer_wire_handshake_and_frames(
info_hash: [u8; 20],
peer_id: [u8; 20],
frames: &[PeerWireMessageKind],
) -> Vec<u8> {
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
}
#[derive(Debug)]
pub(super) struct FakePeerWireConnector {
pub(super) response_payload: Vec<u8>,
pub(super) seen: Mutex<Vec<PeerWireTransportRequest>>,
}
impl FakePeerWireConnector {
pub(super) fn new(response_payload: Vec<u8>) -> Self {
Self {
response_payload,
seen: Mutex::new(Vec::new()),
}
}
pub(super) fn seen(&self) -> Vec<PeerWireTransportRequest> {
self.seen
.lock()
.expect("peer-wire seen requests mutex should not be poisoned")
.clone()
}
}
impl PeerWireTransportConnector for FakePeerWireConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
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(),
})
}
}
#[derive(Debug)]
pub(super) struct FixedPeerWireConnector {
pub(super) response_payload: Vec<u8>,
}
impl PeerWireTransportConnector for FixedPeerWireConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: request.endpoint.address.clone(),
},
payload: self.response_payload.clone(),
})
}
}
pub(super) struct FixedDhtTransport {
pub(super) response: DhtMessageModel,
}
impl DhtTransport for FixedDhtTransport {
fn send_message(
&self,
_node: &DhtNodeModel,
_message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
Ok(self.response.clone())
}
}
#[derive(Debug)]
pub(super) struct RecordingDhtTransport {
pub(super) response: DhtMessageModel,
pub(super) seen: Mutex<Vec<(String, DhtMessageModel)>>,
}
impl RecordingDhtTransport {
pub(super) fn new(response: DhtMessageModel) -> Self {
Self {
response,
seen: Mutex::new(Vec::new()),
}
}
pub(super) fn seen(&self) -> Vec<(String, DhtMessageModel)> {
self.seen.lock().expect("dht seen mutex").clone()
}
}
impl DhtTransport for RecordingDhtTransport {
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
self.seen
.lock()
.expect("dht seen mutex")
.push((format!("{}:{}", node.address, node.port), message.clone()));
Ok(self.response.clone())
}
}
pub(super) fn peer_wire_payload(
info_hash: [u8; 20],
peer_id: [u8; 20],
frames: &[PeerWireMessageKind],
) -> Vec<u8> {
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
}
pub(super) fn info_hash_bytes(input: &str) -> [u8; 20] {
let encoded = input
.split("xt=urn:btih:")
.nth(1)
.and_then(|rest| rest.split('&').next())
.unwrap_or(input);
let mut out = [0_u8; 20];
for (index, chunk) in encoded.as_bytes().chunks_exact(2).enumerate() {
let hex = std::str::from_utf8(chunk).expect("info hash should stay utf8 hex");
out[index] = u8::from_str_radix(hex, 16).expect("info hash should decode from hex");
}
out
}
pub(super) fn compact_peer(ip: [u8; 4], port: u16) -> Vec<u8> {
let mut out = ip.to_vec();
out.extend_from_slice(&port.to_be_bytes());
out
}
pub(super) fn compact_node(node_tag: u8, ip: [u8; 4], port: u16) -> Vec<u8> {
let mut out = vec![node_tag; 20];
out.extend_from_slice(&ip);
out.extend_from_slice(&port.to_be_bytes());
out
}
@@ -0,0 +1,236 @@
use super::*;
#[test]
fn magnet_with_tracker_tiers_and_dht_hint_preserves_bt_status_shape() {
let magnet = "magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=bt-dht-tiered.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:")
));
assert!(
payload.contains_key("numSeeders"),
"compat bt status should expose numSeeders key"
);
}
other => panic!("unexpected tellStatus payload for tiered magnet: {other:?}"),
}
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
assert!(
matches!(peers.result, Some(RpcValue::Array(_))),
"getPeers should remain type-stable for dht/tiered magnet surface"
);
}
#[test]
fn tracker_announce_reingest_replaces_peer_view_with_latest_snapshot() {
fn bencode_int(value: i64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut out = format!("{}:", value.len()).into_bytes();
out.extend_from_slice(value);
out
}
fn bencode_list(values: Vec<Vec<u8>>) -> Vec<u8> {
let mut out = vec![b'l'];
for value in values {
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_dict(entries: Vec<(&str, Vec<u8>)>) -> Vec<u8> {
let mut out = vec![b'd'];
for (key, value) in entries {
out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes());
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
let magnet = "magnet:?xt=urn:btih:8899aabbccddeeff00112233445566778899aabb&dn=bt-reannounce.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let announce_a = TrackerResponseModel::from_announce_bytes(&bencode_dict(vec![
("interval", bencode_int(1200)),
(
"peers",
bencode_list(vec![bencode_dict(vec![
("ip", bencode_bytes(b"198.51.100.41")),
("port", bencode_int(6001)),
])]),
),
]))
.expect("announce-a should parse");
dispatcher
.apply_tracker_announce_result(&gid, &announce_a)
.expect("announce-a should ingest");
let announce_b = TrackerResponseModel::from_announce_bytes(&bencode_dict(vec![
("interval", bencode_int(900)),
(
"peers",
bencode_list(vec![
bencode_dict(vec![
("ip", bencode_bytes(b"198.51.100.42")),
("port", bencode_int(6002)),
]),
bencode_dict(vec![
("ip", bencode_bytes(b"198.51.100.43")),
("port", bencode_int(6003)),
]),
]),
),
]))
.expect("announce-b should parse");
dispatcher
.apply_tracker_announce_result(&gid, &announce_b)
.expect("announce-b should ingest");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(items)) => {
assert_eq!(
items.len(),
2,
"second announce snapshot should be reflected"
);
let ports = items
.into_iter()
.filter_map(|item| match item {
RpcValue::Object(peer) => peer.get("port").cloned(),
_ => None,
})
.collect::<Vec<_>>();
assert!(ports.contains(&RpcValue::String("6002".to_owned())));
assert!(ports.contains(&RpcValue::String("6003".to_owned())));
}
other => panic!("unexpected getPeers payload after reannounce: {other:?}"),
}
}
#[test]
fn bt_mixed_rpc_surface_smoke_preserves_peer_and_server_type_contracts() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let add_torrent = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let torrent_gid = match add_torrent.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let mut magnet_gids = Vec::new();
for i in 0..20 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bt-mixed-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 4000
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
match add.result {
Some(RpcValue::String(gid)) => magnet_gids.push(gid),
other => panic!("unexpected addUri result in mixed smoke: {other:?}"),
}
}
let mut bt_status_objects = 0usize;
for gid in magnet_gids.iter().chain(std::iter::once(&torrent_gid)) {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(payload.contains_key("status"));
assert!(payload.contains_key("completedLength"));
bt_status_objects += 1;
}
other => panic!("unexpected tellStatus payload in mixed smoke: {other:?}"),
}
let servers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetServers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
assert_eq!(bt_status_objects, magnet_gids.len() + 1);
}