Implement the DIRECT connection-initiation mode so a download can target a peer the client is not already connected to: - Fix the outbound peer handshake. on_connection_established sent build_watch_user() (a server WatchUser message, wrongly framed with get_data()) for outbound connections; it now sends a proper length-prefixed PeerInit (peer code 1) with our own username. own_username is plumbed in via PeerRegistry -> PeerActor; inbound peers (outbound=false) are unchanged. - download() now auto-connects: if there is no control connection to the peer it sends GetPeerAddress and defers the QueueUpload until the outbound stream handshakes, signalled by a new ClientOperation::PeerConnected. A never- connected client still resolves the download to Failed (unit test). - Add Client::connect_peer(username) to open/pre-warm a control connection. Verified end to end against soulfind: a_file_downloads_from_a_peer_via_direct_connection logs a mock peer into the server, advertises its port via SetWaitPort, and the client resolves the address, dials it, and downloads the file — bytes match. The F-serve leg is shared with the passive-download mock.
786 lines
27 KiB
Rust
786 lines
27 KiB
Rust
//! End-to-end integration tests against a real Soulseek server (soulfind).
|
|
//!
|
|
//! These tests are SERVER-OPTIONAL so `cargo test` stays green everywhere:
|
|
//! * If `SOULSEEK_TEST_SERVER=host:port` is set, they connect to it.
|
|
//! * Else if a soulfind binary is found (via `SOULFIND_BIN`, or a sibling
|
|
//! `../soulfind/bin/soulfind` checkout), they spawn it on an ephemeral port
|
|
//! with a throwaway database.
|
|
//! * Else they SKIP with a printed notice (the test still passes).
|
|
//!
|
|
//! To run them against a locally built soulfind:
|
|
//! ```sh
|
|
//! SOULFIND_BIN=/path/to/soulfind \
|
|
//! cargo test -p soulseek-rs-lib --test e2e -- --nocapture
|
|
//! ```
|
|
//!
|
|
//! No external crates are used — the library forbids dependencies, so the
|
|
//! harness sticks to `std` and the library's own public API.
|
|
|
|
#![allow(clippy::doc_markdown)]
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::mpsc::Sender;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use soulseek_rs::message::Message;
|
|
use soulseek_rs::message::server::MessageFactory;
|
|
use soulseek_rs::{Client, ClientSettings, DownloadStatus, PeerAddress};
|
|
|
|
/// A Soulseek server to test against: either a child soulfind process we
|
|
/// spawned, or an external server referenced by `SOULSEEK_TEST_SERVER`.
|
|
struct TestServer {
|
|
host: String,
|
|
port: u16,
|
|
child: Option<Child>,
|
|
db: Option<PathBuf>,
|
|
}
|
|
|
|
impl TestServer {
|
|
/// Resolve a server to test against, or `None` if the suite should skip.
|
|
fn resolve() -> Option<Self> {
|
|
if let Ok(addr) = std::env::var("SOULSEEK_TEST_SERVER") {
|
|
let (host, port) = split_host_port(&addr)?;
|
|
wait_until_listening(&host, port, Duration::from_secs(2))?;
|
|
return Some(Self {
|
|
host,
|
|
port,
|
|
child: None,
|
|
db: None,
|
|
});
|
|
}
|
|
Self::spawn()
|
|
}
|
|
|
|
/// Spawn a local soulfind on an ephemeral port with a throwaway database.
|
|
fn spawn() -> Option<Self> {
|
|
let bin = soulfind_binary()?;
|
|
let port = free_port()?;
|
|
let db = std::env::temp_dir().join(format!("soulfind-e2e-{port}.db"));
|
|
let _ = std::fs::remove_file(&db);
|
|
|
|
let mut child = Command::new(&bin)
|
|
.arg("-p")
|
|
.arg(port.to_string())
|
|
.arg("-d")
|
|
.arg(&db)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.ok()?;
|
|
|
|
if wait_until_listening("127.0.0.1", port, Duration::from_secs(5))
|
|
.is_none()
|
|
{
|
|
// Server never came up (e.g. a toolchain/SQLite issue); skip.
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return None;
|
|
}
|
|
|
|
Some(Self {
|
|
host: "127.0.0.1".to_string(),
|
|
port,
|
|
child: Some(child),
|
|
db: Some(db),
|
|
})
|
|
}
|
|
|
|
fn settings(&self, username: &str, password: &str) -> ClientSettings {
|
|
ClientSettings {
|
|
username: username.to_string(),
|
|
password: password.to_string(),
|
|
server_address: PeerAddress::new(self.host.clone(), self.port),
|
|
enable_listen: false,
|
|
listen_port: 0,
|
|
shared_directory: None,
|
|
}
|
|
}
|
|
|
|
/// Settings with the peer listener enabled on `port`, exercising the
|
|
/// `SetWaitPort` step of the post-login handshake.
|
|
fn listening_settings(
|
|
&self,
|
|
username: &str,
|
|
password: &str,
|
|
port: u16,
|
|
) -> ClientSettings {
|
|
ClientSettings {
|
|
enable_listen: true,
|
|
listen_port: port,
|
|
..self.settings(username, password)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TestServer {
|
|
fn drop(&mut self) {
|
|
if let Some(child) = self.child.as_mut() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
if let Some(db) = self.db.as_ref() {
|
|
let _ = std::fs::remove_file(db);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn split_host_port(addr: &str) -> Option<(String, u16)> {
|
|
let (host, port) = addr.rsplit_once(':')?;
|
|
Some((host.to_string(), port.parse().ok()?))
|
|
}
|
|
|
|
fn soulfind_binary() -> Option<PathBuf> {
|
|
if let Ok(p) = std::env::var("SOULFIND_BIN") {
|
|
let p = PathBuf::from(p);
|
|
return p.exists().then_some(p);
|
|
}
|
|
// Fall back to a `soulfind/bin/soulfind` checkout somewhere above this
|
|
// workspace (the workspace may be nested, e.g. under `.../src/rust/`).
|
|
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
manifest
|
|
.ancestors()
|
|
.map(|dir| dir.join("soulfind/bin/soulfind"))
|
|
.find(|candidate| candidate.exists())
|
|
}
|
|
|
|
fn free_port() -> Option<u16> {
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
|
|
listener.local_addr().ok().map(|addr| addr.port())
|
|
}
|
|
|
|
fn wait_until_listening(
|
|
host: &str,
|
|
port: u16,
|
|
timeout: Duration,
|
|
) -> Option<()> {
|
|
let addr = format!("{host}:{port}");
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
if let Ok(mut addrs) = addr.to_socket_addrs()
|
|
&& let Some(sa) = addrs.next()
|
|
&& TcpStream::connect_timeout(&sa, Duration::from_millis(200))
|
|
.is_ok()
|
|
{
|
|
return Some(());
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Resolve a test server or return early with a skip notice.
|
|
///
|
|
/// Set `SOULSEEK_E2E_REQUIRED=1` (as CI does) to turn a missing server into a
|
|
/// hard failure instead of a silent skip, so the suite genuinely runs there.
|
|
macro_rules! server_or_skip {
|
|
() => {
|
|
match TestServer::resolve() {
|
|
Some(server) => server,
|
|
None => {
|
|
let required = std::env::var("SOULSEEK_E2E_REQUIRED")
|
|
.is_ok_and(|v| v != "0" && !v.is_empty());
|
|
assert!(
|
|
!required,
|
|
"SOULSEEK_E2E_REQUIRED is set but no soulfind server could \
|
|
be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)"
|
|
);
|
|
println!(
|
|
"e2e skipped: no soulfind server (set SOULFIND_BIN or \
|
|
SOULSEEK_TEST_SERVER to run)"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
fn connect_and_login_succeed() {
|
|
let server = server_or_skip!();
|
|
let mut client =
|
|
Client::with_settings(server.settings("e2e_user", "e2e_pw"));
|
|
client.connect().expect("connect to soulfind");
|
|
let logged_in = client.login().expect("login to soulfind");
|
|
assert!(logged_in, "login should succeed (soulfind auto-registers)");
|
|
}
|
|
|
|
#[test]
|
|
fn search_round_trips_without_error() {
|
|
let server = server_or_skip!();
|
|
let mut client =
|
|
Client::with_settings(server.settings("e2e_search", "e2e_pw"));
|
|
client.connect().expect("connect");
|
|
assert!(client.login().expect("login"));
|
|
|
|
// A fresh server has no shared files, so the search simply has to
|
|
// round-trip without error and leave an (empty) queryable result set.
|
|
let query = "nonexistent query xyzzy";
|
|
let _ = client.search(query, Duration::from_secs(2));
|
|
assert!(client.get_search_results(query).is_empty());
|
|
|
|
// The search must also be tracked in client state under its key, proving
|
|
// the request was actually registered and not silently dropped.
|
|
assert!(
|
|
client.get_all_searches().contains_key(query),
|
|
"the issued search should be registered under its query key"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_search_is_forwarded_to_a_connected_peer() {
|
|
let server = server_or_skip!();
|
|
|
|
// The server distributes each search to other connected users, so a second
|
|
// client exercises the *incoming* FileSearch handler. We can't observe that
|
|
// handler's state directly, but if it mishandled the forwarded bytes it
|
|
// would take the receiver's session down — so we prove the receiver is
|
|
// still alive afterwards by round-tripping its own search.
|
|
let mut searcher =
|
|
Client::with_settings(server.settings("e2e_searcher", "pw"));
|
|
let mut receiver =
|
|
Client::with_settings(server.settings("e2e_receiver", "pw"));
|
|
searcher.connect().expect("searcher connect");
|
|
receiver.connect().expect("receiver connect");
|
|
assert!(searcher.login().expect("searcher login"));
|
|
assert!(receiver.login().expect("receiver login"));
|
|
|
|
let _ = searcher.search("some shared song", Duration::from_secs(2));
|
|
|
|
// Receiver stays functional after handling the forwarded search.
|
|
let probe = "receiver still alive";
|
|
let _ = receiver.search(probe, Duration::from_secs(2));
|
|
assert!(receiver.get_all_searches().contains_key(probe));
|
|
}
|
|
|
|
#[test]
|
|
fn a_private_message_is_delivered_between_users() {
|
|
let server = server_or_skip!();
|
|
|
|
// Two logged-in users, one messages the other through the server.
|
|
let mut alice =
|
|
Client::with_settings(server.settings("e2e_alice_pm", "pw"));
|
|
let mut bob = Client::with_settings(server.settings("e2e_bob_pm", "pw"));
|
|
alice.connect().expect("alice connect");
|
|
bob.connect().expect("bob connect");
|
|
assert!(alice.login().expect("alice login"));
|
|
assert!(bob.login().expect("bob login"));
|
|
|
|
let body = "hello bob, this is alice";
|
|
alice
|
|
.send_private_message("e2e_bob_pm", body)
|
|
.expect("send private message");
|
|
|
|
// Delivery is asynchronous; poll Bob's inbox until the message arrives.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut received = Vec::new();
|
|
while Instant::now() < deadline {
|
|
received.extend(bob.take_private_messages());
|
|
if !received.is_empty() {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let message = received
|
|
.iter()
|
|
.find(|m| m.message() == body)
|
|
.expect("bob should receive alice's message");
|
|
assert_eq!(message.username(), "e2e_alice_pm");
|
|
}
|
|
|
|
#[test]
|
|
fn login_succeeds_with_listener_enabled() {
|
|
let server = server_or_skip!();
|
|
|
|
// With the listener enabled the client also sends SetWaitPort during the
|
|
// post-login handshake; the server must accept it and keep the session.
|
|
let port = free_port().expect("free listener port");
|
|
let mut client = Client::with_settings(server.listening_settings(
|
|
"e2e_listener",
|
|
"pw",
|
|
port,
|
|
));
|
|
client.connect().expect("connect with listener");
|
|
assert!(
|
|
client.login().expect("login with listener enabled"),
|
|
"the handshake including SetWaitPort should still log in"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_password_is_rejected() {
|
|
let server = server_or_skip!();
|
|
|
|
// soulfind auto-registers a username on first login and binds it to that
|
|
// password, so a second login with a different password must be rejected.
|
|
let user = "e2e_pw_user";
|
|
let mut first =
|
|
Client::with_settings(server.settings(user, "correct-horse"));
|
|
first.connect().expect("connect (registering login)");
|
|
assert!(
|
|
first.login().expect("first login"),
|
|
"registration should log in"
|
|
);
|
|
drop(first);
|
|
|
|
let mut second =
|
|
Client::with_settings(server.settings(user, "wrong-password"));
|
|
second.connect().expect("connect (wrong password)");
|
|
// The server may signal rejection either as a non-success status or as an
|
|
// authentication error; both mean "not logged in", only `Ok(true)` accepts.
|
|
assert!(
|
|
!matches!(second.login(), Ok(true)),
|
|
"a mismatched password must not be accepted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_clients_can_be_logged_in_together() {
|
|
let server = server_or_skip!();
|
|
|
|
// The server must handle several independent sessions at once — this is
|
|
// the precondition for any peer-to-peer feature routed through it.
|
|
let mut alice = Client::with_settings(server.settings("e2e_alice", "pw_a"));
|
|
let mut bob = Client::with_settings(server.settings("e2e_bob", "pw_b"));
|
|
|
|
alice.connect().expect("alice connect");
|
|
bob.connect().expect("bob connect");
|
|
|
|
assert!(alice.login().expect("alice login"));
|
|
assert!(bob.login().expect("bob login"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Peer-to-peer download coverage.
|
|
//
|
|
// The client cannot serve files (the upload side is not implemented), so a real
|
|
// download is exercised with a minimal in-process "mock uploader" that speaks
|
|
// the peer protocol using the library's own public `Message` wire format. It
|
|
// drives the same path a real peer would after a search: a `P` control
|
|
// connection to our listener, a `QueueUpload` → `TransferRequest` →
|
|
// `TransferResponse` negotiation, then an `F` connection that streams the bytes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Configuration for a one-shot mock uploader.
|
|
struct MockUpload {
|
|
listen_addr: String,
|
|
peer_username: String,
|
|
filename: String,
|
|
content: Vec<u8>,
|
|
token: u32,
|
|
ready: Sender<()>,
|
|
}
|
|
|
|
/// Build a `PeerInit` (peer code 1) frame: `[len][1][username][conn_type][token]`.
|
|
fn peer_init_bytes(username: &str, conn_type: &str, token: u32) -> Vec<u8> {
|
|
let mut m = Message::new();
|
|
m.write_int8(1)
|
|
.write_string(username)
|
|
.write_string(conn_type)
|
|
.write_int32(token);
|
|
m.get_buffer()
|
|
}
|
|
|
|
/// Read one length-prefixed peer message (`[len:4 LE][payload]`) from a blocking
|
|
/// stream. The returned `Message` keeps the length prefix, so `get_message_code`
|
|
/// and `set_pointer(8)` behave exactly as they do inside the library.
|
|
fn read_framed(stream: &mut TcpStream) -> std::io::Result<Message> {
|
|
let mut len_buf = [0u8; 4];
|
|
stream.read_exact(&mut len_buf)?;
|
|
let len = u32::from_le_bytes(len_buf) as usize;
|
|
let mut payload = vec![0u8; len];
|
|
stream.read_exact(&mut payload)?;
|
|
let mut data = len_buf.to_vec();
|
|
data.extend_from_slice(&payload);
|
|
Ok(Message::new_with_data(data))
|
|
}
|
|
|
|
fn connect_retry(addr: &str, timeout: Duration) -> std::io::Result<TcpStream> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
match TcpStream::connect(addr) {
|
|
Ok(stream) => return Ok(stream),
|
|
Err(e) => {
|
|
if Instant::now() >= deadline {
|
|
return Err(e);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_mock_uploader(cfg: &MockUpload) -> std::io::Result<()> {
|
|
// 1. P (control) connection: register ourselves with the downloader.
|
|
let mut p = connect_retry(&cfg.listen_addr, Duration::from_secs(5))?;
|
|
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
p.write_all(&peer_init_bytes(&cfg.peer_username, "P", 0))?;
|
|
p.flush()?;
|
|
let _ = cfg.ready.send(());
|
|
|
|
// 2. Wait for the downloader's QueueUpload (peer code 43).
|
|
loop {
|
|
let mut msg = read_framed(&mut p)?;
|
|
if msg.get_message_code() == 43 {
|
|
msg.set_pointer(8);
|
|
let _requested = msg.read_string();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 3. Offer the transfer with a TransferRequest (peer code 40). The size here
|
|
// becomes the download's expected size, so it must match the content.
|
|
let mut tr = Message::new();
|
|
tr.write_int32(40)
|
|
.write_int32(1) // direction: upload
|
|
.write_int32(cfg.token)
|
|
.write_string(&cfg.filename)
|
|
.write_int64(cfg.content.len() as u64);
|
|
p.write_all(&tr.get_buffer())?;
|
|
p.flush()?;
|
|
|
|
// 4. Wait for the downloader to allow it (TransferResponse, peer code 41).
|
|
loop {
|
|
let msg = read_framed(&mut p)?;
|
|
if msg.get_message_code() == 41 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 5. Open the F (file) connection to the downloader's listener and stream.
|
|
serve_file_over_f(
|
|
&cfg.listen_addr,
|
|
&cfg.peer_username,
|
|
cfg.token,
|
|
&cfg.content,
|
|
)
|
|
}
|
|
|
|
/// Open an F (file transfer) connection to `downloader_addr` and stream
|
|
/// `content`. PeerInit(F) and the 4-byte transfer token are written together so
|
|
/// the token lands in the listener's read buffer, where the download is looked
|
|
/// up by token; the downloader then sends an 8-byte START_DOWNLOAD offset before
|
|
/// we send the bytes.
|
|
fn serve_file_over_f(
|
|
downloader_addr: &str,
|
|
username: &str,
|
|
token: u32,
|
|
content: &[u8],
|
|
) -> std::io::Result<()> {
|
|
let mut f = connect_retry(downloader_addr, Duration::from_secs(5))?;
|
|
f.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
let mut init = peer_init_bytes(username, "F", token);
|
|
init.extend_from_slice(&token.to_le_bytes());
|
|
f.write_all(&init)?;
|
|
f.flush()?;
|
|
|
|
let mut start = [0u8; 8];
|
|
f.read_exact(&mut start)?;
|
|
f.write_all(content)?;
|
|
f.flush()?;
|
|
|
|
// Keep the connection open briefly so the reader drains everything.
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
Ok(())
|
|
}
|
|
|
|
fn unique_download_dir() -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"soulseek-e2e-dl-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_downloads_from_a_peer_over_p_and_f_connections() {
|
|
let server = server_or_skip!();
|
|
|
|
// Downloader: connected to the server with its peer listener enabled.
|
|
let listen_port = free_port().expect("free listen port");
|
|
let mut client = Client::with_settings(server.listening_settings(
|
|
"e2e_downloader",
|
|
"pw",
|
|
listen_port,
|
|
));
|
|
client.connect().expect("connect");
|
|
assert!(client.login().expect("login"));
|
|
|
|
let filename = "mock_song.mp3";
|
|
let content: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
|
|
let size = content.len() as u64;
|
|
let token = 424_242_u32;
|
|
let download_dir = unique_download_dir();
|
|
|
|
// Start the mock uploader; it signals once its P connection is established.
|
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
|
let cfg = MockUpload {
|
|
listen_addr: format!("127.0.0.1:{listen_port}"),
|
|
peer_username: "e2e_mockpeer".to_string(),
|
|
filename: filename.to_string(),
|
|
content: content.clone(),
|
|
token,
|
|
ready: ready_tx,
|
|
};
|
|
let uploader = std::thread::spawn(move || {
|
|
if let Err(e) = run_mock_uploader(&cfg) {
|
|
eprintln!("[mock uploader] {e}");
|
|
}
|
|
});
|
|
|
|
ready_rx
|
|
.recv_timeout(Duration::from_secs(5))
|
|
.expect("mock uploader P connection");
|
|
|
|
// The listener registers an incoming peer under "<username>:direct"; give
|
|
// that registration a moment to complete before queuing the download.
|
|
std::thread::sleep(Duration::from_millis(1500));
|
|
|
|
let (_download, status_rx) = client
|
|
.download(
|
|
filename.to_string(),
|
|
"e2e_mockpeer:direct".to_string(),
|
|
size,
|
|
download_dir.display().to_string(),
|
|
)
|
|
.expect("start download");
|
|
|
|
// Wait for completion via the per-download status channel.
|
|
let mut completed = false;
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while Instant::now() < deadline {
|
|
match status_rx.recv_timeout(Duration::from_millis(500)) {
|
|
Ok(DownloadStatus::Completed) => {
|
|
completed = true;
|
|
break;
|
|
}
|
|
Ok(DownloadStatus::Failed | DownloadStatus::TimedOut) => {
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
if client
|
|
.get_all_downloads()
|
|
.iter()
|
|
.any(|d| matches!(d.status, DownloadStatus::Completed))
|
|
{
|
|
completed = true;
|
|
break;
|
|
}
|
|
}
|
|
let _ = uploader.join();
|
|
|
|
assert!(completed, "the download should reach Completed");
|
|
|
|
let written = std::fs::read(download_dir.join(filename))
|
|
.expect("downloaded file should exist");
|
|
assert_eq!(written, content, "downloaded bytes should match the source");
|
|
|
|
let _ = std::fs::remove_dir_all(&download_dir);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Direct-connection download: the client initiates the peer connection.
|
|
//
|
|
// Here the mock is a server-registered peer. It logs in to soulfind and
|
|
// advertises a listen port (SetWaitPort), so when our client asks the server
|
|
// for the peer's address (GetPeerAddress) and dials it directly, the mock
|
|
// accepts that inbound P connection and then serves the file. This exercises
|
|
// the outbound PeerInit handshake and the auto-connecting download() path.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct MockDirectUpload {
|
|
server_addr: String,
|
|
username: String,
|
|
password: String,
|
|
listen_port: u16,
|
|
downloader_listen_addr: String,
|
|
downloader_username: String,
|
|
filename: String,
|
|
content: Vec<u8>,
|
|
token: u32,
|
|
ready: Sender<()>,
|
|
}
|
|
|
|
fn run_mock_direct_peer(cfg: &MockDirectUpload) -> std::io::Result<()> {
|
|
// 1. Log in to the server so it knows this user is online.
|
|
let mut srv = connect_retry(&cfg.server_addr, Duration::from_secs(5))?;
|
|
srv.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
srv.write_all(
|
|
&MessageFactory::build_login_message(&cfg.username, &cfg.password)
|
|
.get_buffer(),
|
|
)?;
|
|
srv.flush()?;
|
|
loop {
|
|
let msg = read_framed(&mut srv)?;
|
|
if msg.get_message_code() == 1 {
|
|
break; // login response
|
|
}
|
|
}
|
|
|
|
// 2. Bind the peer listener, then advertise its port so the server can hand
|
|
// our address to the downloader. Bind all interfaces: soulfind reports
|
|
// the host's LAN address (not 127.0.0.1), and the downloader dials that.
|
|
let listener = std::net::TcpListener::bind(("0.0.0.0", cfg.listen_port))?;
|
|
srv.write_all(
|
|
&MessageFactory::build_set_wait_port_message(cfg.listen_port)
|
|
.get_buffer(),
|
|
)?;
|
|
srv.flush()?;
|
|
let _ = cfg.ready.send(());
|
|
|
|
// 3. Accept the downloader's inbound P (control) connection and validate its
|
|
// PeerInit (peer code 1, int8 code so the fields start at offset 5). The
|
|
// accept is bounded so a misrouted connection fails the test instead of
|
|
// hanging it.
|
|
listener.set_nonblocking(true)?;
|
|
let accept_deadline = Instant::now() + Duration::from_secs(15);
|
|
let (mut p, _addr) = loop {
|
|
match listener.accept() {
|
|
Ok(pair) => break pair,
|
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
if Instant::now() >= accept_deadline {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
"no inbound P connection from the downloader",
|
|
));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
};
|
|
p.set_nonblocking(false)?;
|
|
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
|
let mut init = read_framed(&mut p)?;
|
|
assert_eq!(init.get_message_code(), 1, "expected inbound PeerInit");
|
|
init.set_pointer(5);
|
|
assert_eq!(init.read_string(), cfg.downloader_username, "PeerInit user");
|
|
assert_eq!(init.read_string(), "P", "PeerInit connection type");
|
|
|
|
// 4. Negotiate the transfer exactly as in the passive path.
|
|
loop {
|
|
let mut msg = read_framed(&mut p)?;
|
|
if msg.get_message_code() == 43 {
|
|
msg.set_pointer(8);
|
|
let _requested = msg.read_string();
|
|
break;
|
|
}
|
|
}
|
|
let mut tr = Message::new();
|
|
tr.write_int32(40)
|
|
.write_int32(1)
|
|
.write_int32(cfg.token)
|
|
.write_string(&cfg.filename)
|
|
.write_int64(cfg.content.len() as u64);
|
|
p.write_all(&tr.get_buffer())?;
|
|
p.flush()?;
|
|
loop {
|
|
let msg = read_framed(&mut p)?;
|
|
if msg.get_message_code() == 41 {
|
|
break; // TransferResponse
|
|
}
|
|
}
|
|
|
|
// 5. Stream the bytes over an F connection to the downloader's listener.
|
|
// `srv` stays in scope so the peer remains online for the whole transfer.
|
|
serve_file_over_f(
|
|
&cfg.downloader_listen_addr,
|
|
&cfg.username,
|
|
cfg.token,
|
|
&cfg.content,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_downloads_from_a_peer_via_direct_connection() {
|
|
let server = server_or_skip!();
|
|
|
|
// Downloader with a listener enabled (needed for the F leg).
|
|
let client_port = free_port().expect("free client listen port");
|
|
let mut client = Client::with_settings(server.listening_settings(
|
|
"e2e_direct_dl",
|
|
"pw",
|
|
client_port,
|
|
));
|
|
client.connect().expect("connect");
|
|
assert!(client.login().expect("login"));
|
|
|
|
let mock_port = free_port().expect("free mock listen port");
|
|
let filename = "direct_song.mp3";
|
|
let content: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
|
|
let size = content.len() as u64;
|
|
let token = 515_151_u32;
|
|
let download_dir = unique_download_dir();
|
|
|
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
|
let cfg = MockDirectUpload {
|
|
server_addr: format!("{}:{}", server.host, server.port),
|
|
username: "e2e_directpeer".to_string(),
|
|
password: "pw".to_string(),
|
|
listen_port: mock_port,
|
|
downloader_listen_addr: format!("127.0.0.1:{client_port}"),
|
|
downloader_username: "e2e_direct_dl".to_string(),
|
|
filename: filename.to_string(),
|
|
content: content.clone(),
|
|
token,
|
|
ready: ready_tx,
|
|
};
|
|
let uploader = std::thread::spawn(move || {
|
|
if let Err(e) = run_mock_direct_peer(&cfg) {
|
|
eprintln!("[mock direct peer] {e}");
|
|
}
|
|
});
|
|
|
|
ready_rx
|
|
.recv_timeout(Duration::from_secs(10))
|
|
.expect("mock direct peer ready");
|
|
// Let the server finish processing SetWaitPort before we resolve the address.
|
|
std::thread::sleep(Duration::from_millis(1000));
|
|
|
|
// Target the plain username so download() takes the direct-connect path.
|
|
let (_download, status_rx) = client
|
|
.download(
|
|
filename.to_string(),
|
|
"e2e_directpeer".to_string(),
|
|
size,
|
|
download_dir.display().to_string(),
|
|
)
|
|
.expect("start download");
|
|
|
|
let mut completed = false;
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while Instant::now() < deadline {
|
|
match status_rx.recv_timeout(Duration::from_millis(500)) {
|
|
Ok(DownloadStatus::Completed) => {
|
|
completed = true;
|
|
break;
|
|
}
|
|
Ok(DownloadStatus::Failed | DownloadStatus::TimedOut) => break,
|
|
_ => {}
|
|
}
|
|
if client
|
|
.get_all_downloads()
|
|
.iter()
|
|
.any(|d| matches!(d.status, DownloadStatus::Completed))
|
|
{
|
|
completed = true;
|
|
break;
|
|
}
|
|
}
|
|
let _ = uploader.join();
|
|
|
|
assert!(completed, "the direct download should reach Completed");
|
|
|
|
let written = std::fs::read(download_dir.join(filename))
|
|
.expect("downloaded file should exist");
|
|
assert_eq!(written, content, "downloaded bytes should match the source");
|
|
|
|
let _ = std::fs::remove_dir_all(&download_dir);
|
|
}
|