Files
soulseek-rs-modded/soulseek-rs-lib/tests/e2e.rs
T
michel f3218475dc feat: browse another user's shared files
Add Client::browse_user (sends GetShareFileList, code 4, connecting first if
needed via the same queue-and-flush path as search replies) and
take_browse_result. A new SharedFileListResponseHandler parses the peer's
code-5 reply into SharedDirectory entries, forwarded as
ClientOperation::BrowseResult and stored per user. Verified end to end: a
second real client browses a sharer and receives its file listing.
2026-07-19 18:56:19 +02:00

1198 lines
41 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::peer::ConnectionType;
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(())
}
/// Log a raw socket in to the server and drain up to the login response,
/// returning the still-open stream (the user stays online while it lives).
fn login_raw(
server_addr: &str,
username: &str,
password: &str,
) -> std::io::Result<TcpStream> {
let mut srv = connect_retry(server_addr, Duration::from_secs(5))?;
srv.set_read_timeout(Some(Duration::from_secs(10)))?;
srv.write_all(
&MessageFactory::build_login_message(username, password).get_buffer(),
)?;
srv.flush()?;
loop {
let msg = read_framed(&mut srv)?;
if msg.get_message_code() == 1 {
break;
}
}
Ok(srv)
}
/// Read framed messages from `stream` until one has `code`, or the deadline
/// passes. Returns the matching message.
fn read_until_code(
stream: &mut TcpStream,
code: u8,
timeout: Duration,
) -> Option<Message> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match read_framed(stream) {
Ok(msg) if msg.get_message_code() == code => return Some(msg),
Ok(_) => {}
Err(_) => return None,
}
}
None
}
/// Like [`read_until_code`] but turns a miss into an `io::Error` for `?`.
fn expect_code(
stream: &mut TcpStream,
code: u8,
timeout: Duration,
) -> std::io::Result<Message> {
read_until_code(stream, code, timeout).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("timed out waiting for message code {code}"),
)
})
}
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);
}
#[test]
fn soulfind_brokers_connect_to_peer_between_users() {
// The firewalled download mode relies on the server forwarding a
// ConnectToPeer request to the target user. Confirm soulfind does this: a
// requester (with a wait port so the server knows its address) asks the
// server to broker a connection to an online target, and the target must
// receive a forwarded ConnectToPeer (server code 18) naming the requester.
let server = server_or_skip!();
let addr = format!("{}:{}", server.host, server.port);
let mut target =
login_raw(&addr, "e2e_broker_target", "pw").expect("target login");
let mut requester =
login_raw(&addr, "e2e_broker_req", "pw").expect("requester login");
let req_port = free_port().expect("free port");
requester
.write_all(
&MessageFactory::build_set_wait_port_message(req_port).get_buffer(),
)
.expect("set wait port");
requester.flush().expect("flush wait port");
let token = 987_654_u32;
requester
.write_all(
&MessageFactory::build_connect_to_peer(
token,
"e2e_broker_target",
ConnectionType::P,
)
.get_buffer(),
)
.expect("send ConnectToPeer");
requester.flush().expect("flush ConnectToPeer");
let mut brokered = read_until_code(&mut target, 18, Duration::from_secs(5))
.expect("target should receive a brokered ConnectToPeer");
brokered.set_pointer(8);
assert_eq!(
brokered.read_string(),
"e2e_broker_req",
"the brokered message should name the requester"
);
}
// ---------------------------------------------------------------------------
// 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_secs(1));
// 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);
}
// ---------------------------------------------------------------------------
// Firewalled download: the peer is unreachable directly, so the connection is
// brokered through the server.
//
// The mock advertises a port nobody listens on, so the downloader's direct
// connection fails. The client then asks the server to broker the connection
// (ConnectToPeer); the mock, reading its server stream, sees the forwarded
// request and connects back to the downloader with a PierceFirewall. That
// pierced connection becomes the P control channel, and the file is served.
// ---------------------------------------------------------------------------
struct MockFirewalledUpload {
server_addr: String,
username: String,
password: String,
bogus_port: u16,
downloader_listen_addr: String,
filename: String,
content: Vec<u8>,
token: u32,
ready: Sender<()>,
}
fn run_mock_firewalled_peer(cfg: &MockFirewalledUpload) -> std::io::Result<()> {
// 1. Log in and advertise a port that nobody listens on, so the downloader's
// direct connection is refused and it falls back to server brokering.
let mut srv = login_raw(&cfg.server_addr, &cfg.username, &cfg.password)?;
srv.write_all(
&MessageFactory::build_set_wait_port_message(cfg.bogus_port)
.get_buffer(),
)?;
srv.flush()?;
let _ = cfg.ready.send(());
// 2. Wait for the server-brokered ConnectToPeer (server code 18) and read
// the correlation token (after username, type, ip and port).
let mut ctp = expect_code(&mut srv, 18, Duration::from_secs(15))?;
ctp.set_pointer(8);
let _who = ctp.read_string();
let _conn_type = ctp.read_string();
let _ip = ctp.read_int32();
let _port = ctp.read_int32();
let connect_token = ctp.read_int32();
// 3. Connect back to the downloader with a PierceFirewall (peer code 0);
// this becomes the P control connection.
let mut p =
connect_retry(&cfg.downloader_listen_addr, Duration::from_secs(5))?;
p.set_read_timeout(Some(Duration::from_secs(10)))?;
p.write_all(
&MessageFactory::build_pierce_firewall_message(connect_token)
.get_buffer(),
)?;
p.flush()?;
// 4. Negotiate the transfer over the pierced connection.
let _queue = expect_code(&mut p, 43, Duration::from_secs(10))?;
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()?;
let _response = expect_code(&mut p, 41, Duration::from_secs(10))?;
// 5. Serve the bytes over an F connection to the downloader's listener.
serve_file_over_f(
&cfg.downloader_listen_addr,
&cfg.username,
cfg.token,
&cfg.content,
)
}
#[test]
fn a_file_downloads_from_a_firewalled_peer_via_server_broker() {
let server = server_or_skip!();
let client_port = free_port().expect("free client listen port");
let mut client = Client::with_settings(server.listening_settings(
"e2e_fw_dl",
"pw",
client_port,
));
client.connect().expect("connect");
assert!(client.login().expect("login"));
let bogus_port = free_port().expect("bogus port"); // advertised, unlistened
let filename = "firewalled_song.mp3";
let content: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
let size = content.len() as u64;
let token = 606_060_u32;
let download_dir = unique_download_dir();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let cfg = MockFirewalledUpload {
server_addr: format!("{}:{}", server.host, server.port),
username: "e2e_fw_peer".to_string(),
password: "pw".to_string(),
bogus_port,
downloader_listen_addr: format!("127.0.0.1:{client_port}"),
filename: filename.to_string(),
content: content.clone(),
token,
ready: ready_tx,
};
let uploader = std::thread::spawn(move || {
if let Err(e) = run_mock_firewalled_peer(&cfg) {
eprintln!("[mock firewalled peer] {e}");
}
});
ready_rx
.recv_timeout(Duration::from_secs(10))
.expect("mock firewalled peer ready");
std::thread::sleep(Duration::from_secs(1));
let (_download, status_rx) = client
.download(
filename.to_string(),
"e2e_fw_peer".to_string(),
size,
download_dir.display().to_string(),
)
.expect("start download");
let mut completed = false;
let deadline = Instant::now() + Duration::from_secs(25);
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 firewalled 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);
}
// ---------------------------------------------------------------------------
// Two real clients: one shares a file, the other searches for it and downloads
// it — the entire search + connect + upload/download stack, no mock peer.
// ---------------------------------------------------------------------------
#[test]
fn two_real_clients_search_and_download() {
let server = server_or_skip!();
// Sharer with one distinctively named file.
let share_dir = unique_download_dir();
let content: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let filename = "e2e_probe_xyzzy.bin";
std::fs::write(share_dir.join(filename), &content).unwrap();
let sharer_port = free_port().expect("sharer port");
let mut sharer = Client::with_settings(ClientSettings {
shared_directory: Some(share_dir.display().to_string()),
..server.listening_settings("e2e_sharer", "pw", sharer_port)
});
sharer.connect().expect("sharer connect");
assert!(sharer.login().expect("sharer login"));
let leecher_port = free_port().expect("leecher port");
let mut leecher = Client::with_settings(server.listening_settings(
"e2e_leecher",
"pw",
leecher_port,
));
leecher.connect().expect("leecher connect");
assert!(leecher.login().expect("leecher login"));
// Let soulfind register both SetWaitPorts before the search resolves peers.
std::thread::sleep(Duration::from_secs(1));
let query = "xyzzy";
let _ = leecher.search(query, Duration::from_secs(3));
// Poll until the sharer's response for our file arrives.
let mut hit: Option<(String, u64)> = None;
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline && hit.is_none() {
for result in leecher.get_search_results(query) {
if result.username == "e2e_sharer" {
for file in &result.files {
if file.name.contains("e2e_probe_xyzzy") {
hit = Some((file.name.clone(), file.size));
}
}
}
}
if hit.is_none() {
std::thread::sleep(Duration::from_millis(200));
}
}
let (result_path, size) =
hit.expect("leecher should find the sharer's file");
assert_eq!(size, content.len() as u64);
// Download it from the sharer.
let download_dir = unique_download_dir();
let (_download, status_rx) = leecher
.download(
result_path.clone(),
"e2e_sharer".to_string(),
size,
download_dir.display().to_string(),
)
.expect("start download");
let mut completed = false;
let deadline = Instant::now() + Duration::from_secs(25);
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 leecher
.get_all_downloads()
.iter()
.any(|d| matches!(d.status, DownloadStatus::Completed))
{
completed = true;
break;
}
}
assert!(completed, "the download should complete");
// The virtual path is backslash-separated; the saved file uses the basename.
let basename = result_path.rsplit(['\\', '/']).next().unwrap();
let written = std::fs::read(download_dir.join(basename))
.expect("downloaded file should exist");
assert_eq!(written, content, "downloaded bytes should match the source");
let _ = std::fs::remove_dir_all(share_dir);
let _ = std::fs::remove_dir_all(download_dir);
}
#[test]
fn browse_a_peers_shared_files() {
let server = server_or_skip!();
let share_dir = unique_download_dir();
std::fs::create_dir_all(share_dir.join("album")).unwrap();
std::fs::write(share_dir.join("album").join("track.flac"), b"xxxx")
.unwrap();
let sharer_port = free_port().expect("sharer port");
let mut sharer = Client::with_settings(ClientSettings {
shared_directory: Some(share_dir.display().to_string()),
..server.listening_settings("e2e_browsee", "pw", sharer_port)
});
sharer.connect().expect("sharer connect");
assert!(sharer.login().expect("sharer login"));
let browser_port = free_port().expect("browser port");
let mut browser = Client::with_settings(server.listening_settings(
"e2e_browser",
"pw",
browser_port,
));
browser.connect().expect("browser connect");
assert!(browser.login().expect("browser login"));
std::thread::sleep(Duration::from_secs(1));
browser.browse_user("e2e_browsee").expect("browse request");
let deadline = Instant::now() + Duration::from_secs(20);
let mut listing = None;
while Instant::now() < deadline {
if let Some(result) = browser.take_browse_result("e2e_browsee") {
listing = Some(result);
break;
}
std::thread::sleep(Duration::from_millis(200));
}
let directories = listing.expect("browser should receive the listing");
assert!(
directories
.iter()
.any(|d| { d.files.iter().any(|(name, _)| name == "track.flac") }),
"the listing should include the shared file"
);
let _ = std::fs::remove_dir_all(share_dir);
}