Merge branch 'develop'
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and pull requests are both welcome, and so is just saying what's missing.
|
||||||
|
No contributor agreement, no template.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
Branch off `develop` and target `develop`. `master` only moves through a release
|
||||||
|
PR.
|
||||||
|
|
||||||
|
Write the subject as a
|
||||||
|
[conventional commit](https://www.conventionalcommits.org), like
|
||||||
|
`fix(lib): route on the full four-byte message code`. release-plz reads these to
|
||||||
|
pick the next version and write the changelog. One change per pull request;
|
||||||
|
small ones land fastest.
|
||||||
|
|
||||||
|
Before you push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs those on Linux, macOS and Windows, plus an end-to-end suite against a
|
||||||
|
real [soulfind](https://github.com/soulfind-dev/soulfind) server. The e2e suites
|
||||||
|
skip when no server is around, so `cargo test` stays green without one.
|
||||||
|
[Development](./README.md#development) has the setup if you want to run them.
|
||||||
|
|
||||||
|
AI-assisted changes are fine, and no need to say so. Keep the trailers out of
|
||||||
|
the commit message: no `Co-Authored-By:` for a model, no session links.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
The command you ran, what you expected, and what happened is plenty.
|
||||||
|
`RUST_LOG=trace` output helps. Don't worry about polishing it.
|
||||||
|
|
||||||
|
Built something on `soulseek-rs-lib`? Open a PR adding it to the README, and
|
||||||
|
reserve a client minor version in
|
||||||
|
[#12](https://github.com/michel/soulseek-rs/issues/12) so it's identifiable on
|
||||||
|
the network.
|
||||||
|
|
||||||
|
Contributions are under MIT, like the rest of the project.
|
||||||
@@ -646,6 +646,12 @@ soulfind instead of skipping.
|
|||||||
points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites
|
points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites
|
||||||
with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
|
with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests both welcome. Branch off `develop`, keep the change
|
||||||
|
small, and see [CONTRIBUTING.md](./CONTRIBUTING.md) for the commit convention
|
||||||
|
and the checks CI runs.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT. See [LICENSE](./LICENSE).
|
MIT. See [LICENSE](./LICENSE).
|
||||||
|
|||||||
@@ -316,7 +316,9 @@ impl Seeder {
|
|||||||
Metrics::bump(&self.metrics.mocks_offline);
|
Metrics::bump(&self.metrics.mocks_offline);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) else {
|
// All interfaces: soulfind reports the host's LAN address for a
|
||||||
|
// loopback login, and the client dials that.
|
||||||
|
let Ok(listener) = TcpListener::bind(("0.0.0.0", port)) else {
|
||||||
Metrics::bump(&self.metrics.mocks_offline);
|
Metrics::bump(&self.metrics.mocks_offline);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -415,7 +417,7 @@ impl SeederState {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let response =
|
let response =
|
||||||
build_file_search_response(&self.name, token, &entries, 1, 0);
|
build_file_search_response(&self.name, token, &entries, 1, 0, 0);
|
||||||
|
|
||||||
let Ok(mut guard) = self.conn.lock() else {
|
let Ok(mut guard) = self.conn.lock() else {
|
||||||
return;
|
return;
|
||||||
@@ -481,7 +483,7 @@ impl SeederState {
|
|||||||
let Ok(init) = read_framed(&mut p) else {
|
let Ok(init) = read_framed(&mut p) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if init.get_message_code() != 1 {
|
if init.get_init_code() != 1 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,7 +632,9 @@ impl Leecher {
|
|||||||
Metrics::bump(&self.metrics.mocks_offline);
|
Metrics::bump(&self.metrics.mocks_offline);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) else {
|
// All interfaces: soulfind reports the host's LAN address for a
|
||||||
|
// loopback login, and the client dials that.
|
||||||
|
let Ok(listener) = TcpListener::bind(("0.0.0.0", port)) else {
|
||||||
Metrics::bump(&self.metrics.mocks_offline);
|
Metrics::bump(&self.metrics.mocks_offline);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -735,7 +739,7 @@ fn accept_file_connection(
|
|||||||
let Ok(mut init) = read_framed(&mut stream) else {
|
let Ok(mut init) = read_framed(&mut stream) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if init.get_message_code() != 1 {
|
if init.get_init_code() != 1 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
init.set_pointer(5);
|
init.set_pointer(5);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ use crate::actor::{Actor, ActorHandle, ConnectionState};
|
|||||||
use crate::client::ClientOperation;
|
use crate::client::ClientOperation;
|
||||||
use crate::dispatcher::MessageDispatcher;
|
use crate::dispatcher::MessageDispatcher;
|
||||||
use crate::message::peer::{
|
use crate::message::peer::{
|
||||||
FileSearchResponse, GetShareFileList, PeerInit, PlaceInQueueRequest,
|
FileSearchResponse, FolderContentsRequest, GetShareFileList, PeerInit,
|
||||||
PlaceInQueueResponse, QueueUploadHandler, SharedDirectory,
|
PlaceInQueueRequest, PlaceInQueueResponse, QueueUploadHandler,
|
||||||
SharedFileListResponseHandler, TransferRequest, TransferResponse,
|
SharedDirectory, SharedFileListResponseHandler, TransferRequest,
|
||||||
UploadDeniedHandler, UploadFailedHandler,
|
TransferResponse, UploadDeniedHandler, UploadFailedHandler,
|
||||||
|
UserInfoRequest,
|
||||||
};
|
};
|
||||||
use crate::message::server::MessageFactory;
|
use crate::message::server::MessageFactory;
|
||||||
use crate::message::{Handlers, Message, MessageReader, MessageType};
|
use crate::message::{Handlers, Message, MessageReader, MessageType};
|
||||||
@@ -46,6 +47,13 @@ pub enum PeerMessage {
|
|||||||
ShareListRequested,
|
ShareListRequested,
|
||||||
/// A peer we are browsing sent us their shared-file listing (code 5).
|
/// A peer we are browsing sent us their shared-file listing (code 5).
|
||||||
ShareListReceived(Vec<SharedDirectory>),
|
ShareListReceived(Vec<SharedDirectory>),
|
||||||
|
/// A peer asked what we say about ourselves (they sent us code 15).
|
||||||
|
UserInfoRequested,
|
||||||
|
/// A peer asked for one folder of our shares (they sent us code 36).
|
||||||
|
FolderContentsRequested {
|
||||||
|
token: u32,
|
||||||
|
folder: String,
|
||||||
|
},
|
||||||
/// Offer the queued file to that peer: send an upload TransferRequest.
|
/// Offer the queued file to that peer: send an upload TransferRequest.
|
||||||
ServeUpload {
|
ServeUpload {
|
||||||
token: u32,
|
token: u32,
|
||||||
@@ -178,6 +186,8 @@ impl PeerActor {
|
|||||||
handlers.register_handler(TransferRequest);
|
handlers.register_handler(TransferRequest);
|
||||||
handlers.register_handler(TransferResponse);
|
handlers.register_handler(TransferResponse);
|
||||||
handlers.register_handler(GetShareFileList);
|
handlers.register_handler(GetShareFileList);
|
||||||
|
handlers.register_handler(FolderContentsRequest);
|
||||||
|
handlers.register_handler(UserInfoRequest);
|
||||||
handlers.register_handler(UploadDeniedHandler);
|
handlers.register_handler(UploadDeniedHandler);
|
||||||
handlers.register_handler(UploadFailedHandler);
|
handlers.register_handler(UploadFailedHandler);
|
||||||
handlers.register_handler(PlaceInQueueRequest);
|
handlers.register_handler(PlaceInQueueRequest);
|
||||||
@@ -268,10 +278,27 @@ impl PeerActor {
|
|||||||
self.handle_serve_upload(token, filename, size);
|
self.handle_serve_upload(token, filename, size);
|
||||||
}
|
}
|
||||||
PeerMessage::ShareListRequested => {
|
PeerMessage::ShareListRequested => {
|
||||||
self.handle_share_list_requested();
|
self.forward(ClientOperation::ShareListRequested {
|
||||||
|
requester_key: self.peer_username(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
PeerMessage::ShareListReceived(directories) => {
|
PeerMessage::ShareListReceived(directories) => {
|
||||||
self.handle_share_list_received(directories);
|
self.forward(ClientOperation::BrowseResult {
|
||||||
|
username: self.peer_username(),
|
||||||
|
directories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
PeerMessage::UserInfoRequested => {
|
||||||
|
self.forward(ClientOperation::UserInfoRequested {
|
||||||
|
requester_key: self.peer_username(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
PeerMessage::FolderContentsRequested { token, folder } => {
|
||||||
|
self.forward(ClientOperation::FolderContentsRequested {
|
||||||
|
requester_key: self.peer_username(),
|
||||||
|
token,
|
||||||
|
folder,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
PeerMessage::RequestTransfer(download) => {
|
PeerMessage::RequestTransfer(download) => {
|
||||||
let message = MessageFactory::build_transfer_request_message(
|
let message = MessageFactory::build_transfer_request_message(
|
||||||
@@ -431,25 +458,9 @@ impl PeerActor {
|
|||||||
self.send_message(message);
|
self.send_message(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_share_list_requested(&self) {
|
fn forward(&self, operation: ClientOperation) {
|
||||||
let requester_key = self.peer_username();
|
if let Err(e) = self.client_channel.send(operation) {
|
||||||
if let Err(e) = self
|
error!("[peer_actor] forward to client: {}", e);
|
||||||
.client_channel
|
|
||||||
.send(ClientOperation::ShareListRequested { requester_key })
|
|
||||||
{
|
|
||||||
error!("[peer_actor] forward ShareListRequested: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_share_list_received(&self, directories: Vec<SharedDirectory>) {
|
|
||||||
let username = self.peer_username();
|
|
||||||
if let Err(e) =
|
|
||||||
self.client_channel.send(ClientOperation::BrowseResult {
|
|
||||||
username,
|
|
||||||
directories,
|
|
||||||
})
|
|
||||||
{
|
|
||||||
error!("[peer_actor] forward BrowseResult: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +546,7 @@ impl PeerActor {
|
|||||||
message
|
message
|
||||||
.get_message_name(
|
.get_message_name(
|
||||||
MessageType::Peer,
|
MessageType::Peer,
|
||||||
u32::from(message.get_message_code())
|
message.get_message_code()
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::actor::{Actor, ActorHandle, ConnectionState};
|
use crate::actor::{Actor, ActorHandle, ConnectionState};
|
||||||
use crate::client::ClientOperation;
|
use crate::client::ClientOperation;
|
||||||
use crate::dispatcher::MessageDispatcher;
|
use crate::dispatcher::MessageDispatcher;
|
||||||
|
use crate::message::server::AdminMessageHandler;
|
||||||
use crate::message::server::CheckPrivilegesHandler;
|
use crate::message::server::CheckPrivilegesHandler;
|
||||||
use crate::message::server::ConnectToPeerHandler;
|
use crate::message::server::ConnectToPeerHandler;
|
||||||
use crate::message::server::ExcludedSearchPhrasesHandler;
|
use crate::message::server::ExcludedSearchPhrasesHandler;
|
||||||
@@ -41,6 +42,9 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use crate::{SoulseekRs, debug, error, trace, warn};
|
use crate::{SoulseekRs, debug, error, trace, warn};
|
||||||
|
|
||||||
|
mod types;
|
||||||
|
pub use types::{Context, PeerAddress, UserMessage};
|
||||||
|
|
||||||
/// Ceiling on the wait for the server's login verdict. A loaded server can
|
/// Ceiling on the wait for the server's login verdict. A loaded server can
|
||||||
/// take seconds to answer, so this stays inside the caller's own 45s bound
|
/// take seconds to answer, so this stays inside the caller's own 45s bound
|
||||||
/// rather than undercutting it.
|
/// rather than undercutting it.
|
||||||
@@ -48,103 +52,6 @@ const LOGIN_VERDICT_TIMEOUT: Duration = Duration::from_secs(30);
|
|||||||
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PeerAddress {
|
|
||||||
host: String,
|
|
||||||
port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PeerAddress {
|
|
||||||
#[must_use]
|
|
||||||
pub const fn new(host: String, port: u16) -> Self {
|
|
||||||
Self { host, port }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get_host(&self) -> &str {
|
|
||||||
&self.host
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub const fn get_port(&self) -> u16 {
|
|
||||||
self.port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for PeerAddress {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
||||||
write!(f, "{}:{}", self.host, self.port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct Context {
|
|
||||||
pub logged_in: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Context {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UserMessage {
|
|
||||||
id: u32,
|
|
||||||
timestamp: u32,
|
|
||||||
username: String,
|
|
||||||
message: String,
|
|
||||||
new_message: bool,
|
|
||||||
}
|
|
||||||
impl UserMessage {
|
|
||||||
#[must_use]
|
|
||||||
pub const fn new(
|
|
||||||
id: u32,
|
|
||||||
timestamp: u32,
|
|
||||||
username: String,
|
|
||||||
message: String,
|
|
||||||
new_message: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
timestamp,
|
|
||||||
username,
|
|
||||||
message,
|
|
||||||
new_message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// The server-assigned id of this message (used to acknowledge it).
|
|
||||||
#[must_use]
|
|
||||||
pub const fn id(&self) -> u32 {
|
|
||||||
self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unix timestamp the server recorded for this message.
|
|
||||||
#[must_use]
|
|
||||||
pub const fn timestamp(&self) -> u32 {
|
|
||||||
self.timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The username of the sender.
|
|
||||||
#[must_use]
|
|
||||||
pub fn username(&self) -> &str {
|
|
||||||
&self.username
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The message body.
|
|
||||||
#[must_use]
|
|
||||||
pub fn message(&self) -> &str {
|
|
||||||
&self.message
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the server flagged this as freshly delivered (as opposed to a
|
|
||||||
/// message replayed because it was queued while the recipient was offline).
|
|
||||||
#[must_use]
|
|
||||||
pub const fn is_new(&self) -> bool {
|
|
||||||
self.new_message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
|
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
|
||||||
/// the client learns adds a variant, and that must not break callers.
|
/// the client learns adds a variant, and that must not break callers.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -330,7 +237,7 @@ impl ServerActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn initiate_connection(&mut self) -> bool {
|
fn initiate_connection(&mut self) -> bool {
|
||||||
let stream = (self.address.host.as_str(), self.address.port)
|
let stream = (self.address.get_host(), self.address.get_port())
|
||||||
.to_socket_addrs()
|
.to_socket_addrs()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|mut addrs| {
|
.and_then(|mut addrs| {
|
||||||
@@ -350,6 +257,9 @@ impl ServerActor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
stream.set_nodelay(true).ok();
|
stream.set_nodelay(true).ok();
|
||||||
|
if let Err(e) = crate::utils::keepalive::set_keepalive(&stream) {
|
||||||
|
warn!("[server] keepalive not set: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
self.stream = Some(stream);
|
self.stream = Some(stream);
|
||||||
self.connection_state = ConnectionState::Connecting {
|
self.connection_state = ConnectionState::Connecting {
|
||||||
@@ -380,6 +290,7 @@ impl ServerActor {
|
|||||||
|
|
||||||
handlers.register_handler(LoginHandler);
|
handlers.register_handler(LoginHandler);
|
||||||
handlers.register_handler(ReloggedHandler);
|
handlers.register_handler(ReloggedHandler);
|
||||||
|
handlers.register_handler(AdminMessageHandler);
|
||||||
handlers.register_handler(RoomListHandler);
|
handlers.register_handler(RoomListHandler);
|
||||||
handlers.register_handler(GetUserStatusHandler);
|
handlers.register_handler(GetUserStatusHandler);
|
||||||
handlers.register_handler(WatchUserHandler);
|
handlers.register_handler(WatchUserHandler);
|
||||||
@@ -474,6 +385,36 @@ impl ServerActor {
|
|||||||
ServerMessage::PrivateMessageReceived(user_message) => {
|
ServerMessage::PrivateMessageReceived(user_message) => {
|
||||||
self.handle_private_message_received(user_message);
|
self.handle_private_message_received(user_message);
|
||||||
}
|
}
|
||||||
|
ServerMessage::ProcessRead => {
|
||||||
|
self.process_read();
|
||||||
|
}
|
||||||
|
ServerMessage::Login {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
version,
|
||||||
|
response,
|
||||||
|
} => {
|
||||||
|
self.handle_login(username, password, version, response);
|
||||||
|
}
|
||||||
|
ServerMessage::FileSearch { token, query } => {
|
||||||
|
self.file_search(token, &query);
|
||||||
|
}
|
||||||
|
ServerMessage::FileSearchRequest {
|
||||||
|
username,
|
||||||
|
token,
|
||||||
|
query,
|
||||||
|
} => {
|
||||||
|
self.handle_file_search_request(username, token, query);
|
||||||
|
}
|
||||||
|
other => self.handle_social_message(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events
|
||||||
|
/// and user status or stats. Split out, like the standing traffic below,
|
||||||
|
/// only to keep `handle_message`'s match readable.
|
||||||
|
fn handle_social_message(&mut self, message: ServerMessage) {
|
||||||
|
match message {
|
||||||
ServerMessage::RoomListReceived(rooms) => {
|
ServerMessage::RoomListReceived(rooms) => {
|
||||||
self.forward_room_event(RoomEvent::List(rooms));
|
self.forward_room_event(RoomEvent::List(rooms));
|
||||||
}
|
}
|
||||||
@@ -550,27 +491,6 @@ impl ServerActor {
|
|||||||
ServerMessage::RoomUserLeft { room, username } => {
|
ServerMessage::RoomUserLeft { room, username } => {
|
||||||
self.forward_room_event(RoomEvent::UserLeft { room, username });
|
self.forward_room_event(RoomEvent::UserLeft { room, username });
|
||||||
}
|
}
|
||||||
ServerMessage::ProcessRead => {
|
|
||||||
self.process_read();
|
|
||||||
}
|
|
||||||
ServerMessage::Login {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
version,
|
|
||||||
response,
|
|
||||||
} => {
|
|
||||||
self.handle_login(username, password, version, response);
|
|
||||||
}
|
|
||||||
ServerMessage::FileSearch { token, query } => {
|
|
||||||
self.file_search(token, &query);
|
|
||||||
}
|
|
||||||
ServerMessage::FileSearchRequest {
|
|
||||||
username,
|
|
||||||
token,
|
|
||||||
query,
|
|
||||||
} => {
|
|
||||||
self.handle_file_search_request(username, token, query);
|
|
||||||
}
|
|
||||||
other => self.handle_standing_message(other),
|
other => self.handle_standing_message(other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -803,7 +723,7 @@ impl ServerActor {
|
|||||||
message
|
message
|
||||||
.get_message_name(
|
.get_message_name(
|
||||||
MessageType::Server,
|
MessageType::Server,
|
||||||
u32::from(message.get_message_code())
|
message.get_message_code()
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
);
|
);
|
||||||
@@ -998,205 +918,4 @@ impl Actor for ServerActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn code_of(message: &Message) -> u32 {
|
|
||||||
u32::from_le_bytes(message.get_data()[0..4].try_into().unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_timed_out_connect_parks_the_actor_in_disconnected() {
|
|
||||||
let mut actor = parked_actor(1);
|
|
||||||
actor.connection_state = ConnectionState::Connecting {
|
|
||||||
since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(),
|
|
||||||
};
|
|
||||||
|
|
||||||
actor.tick();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(actor.connection_state, ConnectionState::Disconnected),
|
|
||||||
"a timed-out connect must leave Connecting"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parked_actor(port: u16) -> ServerActor {
|
|
||||||
let (tx, _rx) = std::sync::mpsc::channel();
|
|
||||||
ServerActor::new(
|
|
||||||
PeerAddress::new("127.0.0.1".to_string(), port),
|
|
||||||
tx,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dropping_the_connection_forgets_the_dispatcher_and_the_verdict() {
|
|
||||||
let mut actor = parked_actor(1);
|
|
||||||
actor.initialize_dispatcher();
|
|
||||||
actor.connection_state = ConnectionState::Connected;
|
|
||||||
actor.handle_login_status(true);
|
|
||||||
|
|
||||||
actor.disconnect_with_error();
|
|
||||||
|
|
||||||
assert_eq!(actor.session.loss(), Some(SessionLoss::Disconnected));
|
|
||||||
assert!(
|
|
||||||
actor.dispatcher_sender.is_none(),
|
|
||||||
"a login queued now must wait in the buffer, not in a channel \
|
|
||||||
the next connection replaces"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
actor.context.read().unwrap().logged_in,
|
|
||||||
None,
|
|
||||||
"the old verdict must not answer the next login"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disconnect_clears_a_partial_frame_so_the_next_session_reframes_clean() {
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let client = TcpStream::connect(addr).unwrap();
|
|
||||||
let (mut server_side, _) = listener.accept().unwrap();
|
|
||||||
let partial_frame = [10u8, 0, 0, 0, 1, 2, 3];
|
|
||||||
server_side.write_all(&partial_frame).unwrap();
|
|
||||||
server_side.flush().unwrap();
|
|
||||||
client.set_nonblocking(true).unwrap();
|
|
||||||
|
|
||||||
let mut actor = parked_actor(addr.port());
|
|
||||||
actor.stream = Some(client);
|
|
||||||
actor.connection_state = ConnectionState::Connected;
|
|
||||||
for _ in 0..50 {
|
|
||||||
actor.process_read();
|
|
||||||
if actor.reader.buffer_len() > 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
actor.reader.buffer_len() > 0,
|
|
||||||
"an incomplete frame should be waiting for its rest"
|
|
||||||
);
|
|
||||||
|
|
||||||
actor.disconnect();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
actor.reader.buffer_len(),
|
|
||||||
0,
|
|
||||||
"a stale partial frame carried across a reconnect reads the next \
|
|
||||||
session's bytes as a length prefix and wedges recovery"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_login_on_a_parked_actor_dials_again_and_queues_the_login() {
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let mut actor = parked_actor(listener.local_addr().unwrap().port());
|
|
||||||
actor.session.record(SessionLoss::Disconnected);
|
|
||||||
let (response, _verdict) = std::sync::mpsc::channel();
|
|
||||||
|
|
||||||
actor.handle_login(
|
|
||||||
"u".into(),
|
|
||||||
"p".into(),
|
|
||||||
ClientVersion::default(),
|
|
||||||
response,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
actor.connection_state,
|
|
||||||
ConnectionState::Connecting { .. }
|
|
||||||
),
|
|
||||||
"a parked actor must dial again on login"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
actor.queued_messages.len(),
|
|
||||||
1,
|
|
||||||
"the login waits for the connection to come up"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_parked_actor_handles_login_rather_than_queueing_it() {
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let mut actor = parked_actor(listener.local_addr().unwrap().port());
|
|
||||||
actor.session.record(SessionLoss::Disconnected);
|
|
||||||
let (response, _verdict) = std::sync::mpsc::channel();
|
|
||||||
|
|
||||||
actor.handle_message(ServerMessage::Login {
|
|
||||||
username: "u".into(),
|
|
||||||
password: "p".into(),
|
|
||||||
version: ClientVersion::default(),
|
|
||||||
response,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
actor.connection_state,
|
|
||||||
ConnectionState::Connecting { .. }
|
|
||||||
),
|
|
||||||
"a parked actor must act on a login, not park it in the queue"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
actor
|
|
||||||
.queued_messages
|
|
||||||
.iter()
|
|
||||||
.all(|m| !matches!(m, ServerMessage::Login { .. })),
|
|
||||||
"the login must not be sitting unhandled in the queue"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_login_with_nobody_listening_is_refused_at_once() {
|
|
||||||
let mut actor = parked_actor(1);
|
|
||||||
let (response, verdict) = std::sync::mpsc::channel();
|
|
||||||
|
|
||||||
actor.handle_login(
|
|
||||||
"u".into(),
|
|
||||||
"p".into(),
|
|
||||||
ClientVersion::default(),
|
|
||||||
response,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
verdict.recv_timeout(Duration::from_secs(5)),
|
|
||||||
Ok(Err(SoulseekRs::NotConnected))
|
|
||||||
),
|
|
||||||
"a refused dial must not make the caller wait out the verdict \
|
|
||||||
timeout"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_successful_login_marks_the_session_live_again() {
|
|
||||||
let mut actor = parked_actor(1);
|
|
||||||
actor.session.record(SessionLoss::Disconnected);
|
|
||||||
|
|
||||||
actor.handle_login_status(true);
|
|
||||||
|
|
||||||
assert_eq!(actor.session.loss(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn post_login_messages_carry_counts_and_conditional_wait_port() {
|
|
||||||
let messages = post_login_messages(true, 4321, 3, 7);
|
|
||||||
let codes: Vec<u32> = messages.iter().map(code_of).collect();
|
|
||||||
// SharedFolders, HaveNoParent, SetStatus, SetWaitPort.
|
|
||||||
assert_eq!(codes, vec![35, 71, 28, 2]);
|
|
||||||
|
|
||||||
// The SharedFolders message (code 35) carries the real counts.
|
|
||||||
let shared = messages[0].get_data();
|
|
||||||
assert_eq!(u32::from_le_bytes(shared[4..8].try_into().unwrap()), 3);
|
|
||||||
assert_eq!(u32::from_le_bytes(shared[8..12].try_into().unwrap()), 7);
|
|
||||||
|
|
||||||
// Not listening omits SetWaitPort (code 2).
|
|
||||||
let no_listen = post_login_messages(false, 4321, 3, 7);
|
|
||||||
let codes: Vec<u32> = no_listen.iter().map(code_of).collect();
|
|
||||||
assert_eq!(codes, vec![35, 71, 28]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn code_of(message: &Message) -> u32 {
|
||||||
|
u32::from_le_bytes(message.get_data()[0..4].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_timed_out_connect_parks_the_actor_in_disconnected() {
|
||||||
|
let mut actor = parked_actor(1);
|
||||||
|
actor.connection_state = ConnectionState::Connecting {
|
||||||
|
since: Instant::now().checked_sub(Duration::from_secs(21)).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
actor.tick();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(actor.connection_state, ConnectionState::Disconnected),
|
||||||
|
"a timed-out connect must leave Connecting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn the_server_socket_keeps_itself_alive() {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let mut actor = parked_actor(listener.local_addr().unwrap().port());
|
||||||
|
|
||||||
|
assert!(actor.initiate_connection());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
crate::utils::keepalive::keepalive_enabled(
|
||||||
|
actor.stream.as_ref().unwrap()
|
||||||
|
),
|
||||||
|
"an idle NAT mapping must be kept alive and a dead path noticed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parked_actor(port: u16) -> ServerActor {
|
||||||
|
let (tx, _rx) = std::sync::mpsc::channel();
|
||||||
|
ServerActor::new(
|
||||||
|
PeerAddress::new("127.0.0.1".to_string(), port),
|
||||||
|
tx,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_the_connection_forgets_the_dispatcher_and_the_verdict() {
|
||||||
|
let mut actor = parked_actor(1);
|
||||||
|
actor.initialize_dispatcher();
|
||||||
|
actor.connection_state = ConnectionState::Connected;
|
||||||
|
actor.handle_login_status(true);
|
||||||
|
|
||||||
|
actor.disconnect_with_error();
|
||||||
|
|
||||||
|
assert_eq!(actor.session.loss(), Some(SessionLoss::Disconnected));
|
||||||
|
assert!(
|
||||||
|
actor.dispatcher_sender.is_none(),
|
||||||
|
"a login queued now must wait in the buffer, not in a channel \
|
||||||
|
the next connection replaces"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
actor.context.read().unwrap().logged_in,
|
||||||
|
None,
|
||||||
|
"the old verdict must not answer the next login"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_clears_a_partial_frame_so_the_next_session_reframes_clean() {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let client = TcpStream::connect(addr).unwrap();
|
||||||
|
let (mut server_side, _) = listener.accept().unwrap();
|
||||||
|
let partial_frame = [10u8, 0, 0, 0, 1, 2, 3];
|
||||||
|
server_side.write_all(&partial_frame).unwrap();
|
||||||
|
server_side.flush().unwrap();
|
||||||
|
client.set_nonblocking(true).unwrap();
|
||||||
|
|
||||||
|
let mut actor = parked_actor(addr.port());
|
||||||
|
actor.stream = Some(client);
|
||||||
|
actor.connection_state = ConnectionState::Connected;
|
||||||
|
for _ in 0..50 {
|
||||||
|
actor.process_read();
|
||||||
|
if actor.reader.buffer_len() > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
actor.reader.buffer_len() > 0,
|
||||||
|
"an incomplete frame should be waiting for its rest"
|
||||||
|
);
|
||||||
|
|
||||||
|
actor.disconnect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actor.reader.buffer_len(),
|
||||||
|
0,
|
||||||
|
"a stale partial frame carried across a reconnect reads the next \
|
||||||
|
session's bytes as a length prefix and wedges recovery"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_login_on_a_parked_actor_dials_again_and_queues_the_login() {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let mut actor = parked_actor(listener.local_addr().unwrap().port());
|
||||||
|
actor.session.record(SessionLoss::Disconnected);
|
||||||
|
let (response, _verdict) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
actor.handle_login(
|
||||||
|
"u".into(),
|
||||||
|
"p".into(),
|
||||||
|
ClientVersion::default(),
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(actor.connection_state, ConnectionState::Connecting { .. }),
|
||||||
|
"a parked actor must dial again on login"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
actor.queued_messages.len(),
|
||||||
|
1,
|
||||||
|
"the login waits for the connection to come up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_parked_actor_handles_login_rather_than_queueing_it() {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let mut actor = parked_actor(listener.local_addr().unwrap().port());
|
||||||
|
actor.session.record(SessionLoss::Disconnected);
|
||||||
|
let (response, _verdict) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
actor.handle_message(ServerMessage::Login {
|
||||||
|
username: "u".into(),
|
||||||
|
password: "p".into(),
|
||||||
|
version: ClientVersion::default(),
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(actor.connection_state, ConnectionState::Connecting { .. }),
|
||||||
|
"a parked actor must act on a login, not park it in the queue"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
actor
|
||||||
|
.queued_messages
|
||||||
|
.iter()
|
||||||
|
.all(|m| !matches!(m, ServerMessage::Login { .. })),
|
||||||
|
"the login must not be sitting unhandled in the queue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_login_with_nobody_listening_is_refused_at_once() {
|
||||||
|
let mut actor = parked_actor(1);
|
||||||
|
let (response, verdict) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
actor.handle_login(
|
||||||
|
"u".into(),
|
||||||
|
"p".into(),
|
||||||
|
ClientVersion::default(),
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
verdict.recv_timeout(Duration::from_secs(5)),
|
||||||
|
Ok(Err(SoulseekRs::NotConnected))
|
||||||
|
),
|
||||||
|
"a refused dial must not make the caller wait out the verdict \
|
||||||
|
timeout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_successful_login_marks_the_session_live_again() {
|
||||||
|
let mut actor = parked_actor(1);
|
||||||
|
actor.session.record(SessionLoss::Disconnected);
|
||||||
|
|
||||||
|
actor.handle_login_status(true);
|
||||||
|
|
||||||
|
assert_eq!(actor.session.loss(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_login_messages_carry_counts_and_conditional_wait_port() {
|
||||||
|
let messages = post_login_messages(true, 4321, 3, 7);
|
||||||
|
let codes: Vec<u32> = messages.iter().map(code_of).collect();
|
||||||
|
// SharedFolders, HaveNoParent, SetStatus, SetWaitPort.
|
||||||
|
assert_eq!(codes, vec![35, 71, 28, 2]);
|
||||||
|
|
||||||
|
// The SharedFolders message (code 35) carries the real counts.
|
||||||
|
let shared = messages[0].get_data();
|
||||||
|
assert_eq!(u32::from_le_bytes(shared[4..8].try_into().unwrap()), 3);
|
||||||
|
assert_eq!(u32::from_le_bytes(shared[8..12].try_into().unwrap()), 7);
|
||||||
|
|
||||||
|
// Not listening omits SetWaitPort (code 2).
|
||||||
|
let no_listen = post_login_messages(false, 4321, 3, 7);
|
||||||
|
let codes: Vec<u32> = no_listen.iter().map(code_of).collect();
|
||||||
|
assert_eq!(codes, vec![35, 71, 28]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! The values the server actor hands out: where a peer listens, a private
|
||||||
|
//! message, and the login context.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PeerAddress {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeerAddress {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(host: String, port: u16) -> Self {
|
||||||
|
Self { host, port }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_host(&self) -> &str {
|
||||||
|
&self.host
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn get_port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PeerAddress {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(f, "{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Context {
|
||||||
|
pub logged_in: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Context {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UserMessage {
|
||||||
|
id: u32,
|
||||||
|
timestamp: u32,
|
||||||
|
username: String,
|
||||||
|
message: String,
|
||||||
|
new_message: bool,
|
||||||
|
}
|
||||||
|
impl UserMessage {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
id: u32,
|
||||||
|
timestamp: u32,
|
||||||
|
username: String,
|
||||||
|
message: String,
|
||||||
|
new_message: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
username,
|
||||||
|
message,
|
||||||
|
new_message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// The server-assigned id of this message (used to acknowledge it).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u32 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix timestamp the server recorded for this message.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn timestamp(&self) -> u32 {
|
||||||
|
self.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The username of the sender.
|
||||||
|
#[must_use]
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
&self.username
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message body.
|
||||||
|
#[must_use]
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server flagged this as freshly delivered (as opposed to a
|
||||||
|
/// message replayed because it was queued while the recipient was offline).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_new(&self) -> bool {
|
||||||
|
self.new_message
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
peer::{ConnectionType, DownloadPeer, Peer, PeerMessage, listen::Listen},
|
peer::{ConnectionType, DownloadPeer, Peer, PeerMessage, listen::Listen},
|
||||||
shares::Shares,
|
shares::Shares,
|
||||||
types::{Download, Search, SearchResult},
|
types::{Download, Search, SearchResult},
|
||||||
utils::{lock::RwLockExt, md5},
|
utils::lock::RwLockExt,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
@@ -90,6 +90,17 @@ fn next_download_token() -> u32 {
|
|||||||
NEXT_DOWNLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) % 0x8000_0000
|
NEXT_DOWNLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) % 0x8000_0000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Source of search tokens.
|
||||||
|
///
|
||||||
|
/// A counter, not a hash of the query: a peer's answer is routed to the first
|
||||||
|
/// search holding its token, so two queries sharing one would pour results
|
||||||
|
/// into each other.
|
||||||
|
static NEXT_SEARCH_TOKEN: AtomicU32 = AtomicU32::new(1);
|
||||||
|
|
||||||
|
fn next_search_token() -> u32 {
|
||||||
|
NEXT_SEARCH_TOKEN.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// A file we have agreed to serve to a peer, awaiting their TransferResponse.
|
/// A file we have agreed to serve to a peer, awaiting their TransferResponse.
|
||||||
struct UploadJob {
|
struct UploadJob {
|
||||||
downloader: String,
|
downloader: String,
|
||||||
@@ -139,6 +150,9 @@ fn build_search_response(
|
|||||||
own_username: &str,
|
own_username: &str,
|
||||||
token: u32,
|
token: u32,
|
||||||
query: &str,
|
query: &str,
|
||||||
|
free_slot: bool,
|
||||||
|
speed: u32,
|
||||||
|
queue_length: u32,
|
||||||
) -> Option<crate::message::Message> {
|
) -> Option<crate::message::Message> {
|
||||||
let matches = shares.search(query);
|
let matches = shares.search(query);
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
@@ -146,6 +160,7 @@ fn build_search_response(
|
|||||||
}
|
}
|
||||||
let entries: Vec<FileEntry> = matches
|
let entries: Vec<FileEntry> = matches
|
||||||
.iter()
|
.iter()
|
||||||
|
.take(crate::types::MAX_SEARCH_REPLY_FILES)
|
||||||
.map(|f| FileEntry {
|
.map(|f| FileEntry {
|
||||||
name: &f.virtual_path,
|
name: &f.virtual_path,
|
||||||
size: f.size,
|
size: f.size,
|
||||||
@@ -156,8 +171,9 @@ fn build_search_response(
|
|||||||
own_username,
|
own_username,
|
||||||
token,
|
token,
|
||||||
&entries,
|
&entries,
|
||||||
1,
|
u8::from(free_slot),
|
||||||
0,
|
speed,
|
||||||
|
queue_length,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +292,16 @@ pub enum ClientOperation {
|
|||||||
ShareListRequested {
|
ShareListRequested {
|
||||||
requester_key: String,
|
requester_key: String,
|
||||||
},
|
},
|
||||||
|
/// A peer asked what we say about ourselves; send a UserInfoResponse.
|
||||||
|
UserInfoRequested {
|
||||||
|
requester_key: String,
|
||||||
|
},
|
||||||
|
/// A peer asked for one folder of our shares; send a FolderContentsResponse.
|
||||||
|
FolderContentsRequested {
|
||||||
|
requester_key: String,
|
||||||
|
token: u32,
|
||||||
|
folder: String,
|
||||||
|
},
|
||||||
/// A peer we are browsing returned their shared-file listing.
|
/// A peer we are browsing returned their shared-file listing.
|
||||||
BrowseResult {
|
BrowseResult {
|
||||||
username: String,
|
username: String,
|
||||||
@@ -364,6 +390,9 @@ pub struct ClientContext {
|
|||||||
upload_seq: u64,
|
upload_seq: u64,
|
||||||
/// How many uploads may be in flight at once.
|
/// How many uploads may be in flight at once.
|
||||||
upload_slots: usize,
|
upload_slots: usize,
|
||||||
|
/// Bytes per second of the last completed upload, advertised in search
|
||||||
|
/// replies; zero until one has finished.
|
||||||
|
last_upload_speed: u32,
|
||||||
/// Queued-upload states that came and went between two polls of
|
/// Queued-upload states that came and went between two polls of
|
||||||
/// [`Client::uploads`]. A caller sampling that snapshot would otherwise
|
/// [`Client::uploads`]. A caller sampling that snapshot would otherwise
|
||||||
/// never see a peer that queued and was served inside one poll interval,
|
/// never see a peer that queued and was served inside one poll interval,
|
||||||
@@ -446,6 +475,7 @@ impl ClientContext {
|
|||||||
upload_queue: Vec::new(),
|
upload_queue: Vec::new(),
|
||||||
upload_seq: 0,
|
upload_seq: 0,
|
||||||
upload_slots: DEFAULT_UPLOAD_SLOTS,
|
upload_slots: DEFAULT_UPLOAD_SLOTS,
|
||||||
|
last_upload_speed: 0,
|
||||||
upload_events: Vec::new(),
|
upload_events: Vec::new(),
|
||||||
downloads: DownloadStore::new(),
|
downloads: DownloadStore::new(),
|
||||||
actor_system,
|
actor_system,
|
||||||
|
|||||||
@@ -593,6 +593,9 @@ impl Client {
|
|||||||
&own_username,
|
&own_username,
|
||||||
token,
|
token,
|
||||||
&query,
|
&query,
|
||||||
|
ctx.has_free_upload_slot(),
|
||||||
|
ctx.last_upload_speed,
|
||||||
|
ctx.upload_queue.len() as u32,
|
||||||
),
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("[client] IncomingSearch read: {}", e);
|
error!("[client] IncomingSearch read: {}", e);
|
||||||
@@ -753,35 +756,49 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClientOperation::ShareListRequested { requester_key } => {
|
ClientOperation::ShareListRequested { requester_key } => {
|
||||||
// Reply with our full shared-file listing.
|
Self::reply_to_peer(
|
||||||
let (registry, message) = match client_context
|
&client_context,
|
||||||
.read_safe()
|
|
||||||
{
|
|
||||||
Ok(ctx) => {
|
|
||||||
let dirs = ctx
|
|
||||||
.shares
|
|
||||||
.directories()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, files)| {
|
|
||||||
crate::message::peer::SharedDirectory {
|
|
||||||
name,
|
|
||||||
files,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
(
|
|
||||||
ctx.peer_registry.clone(),
|
|
||||||
crate::message::peer::build_shared_file_list(&dirs),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if let Some(registry) = registry {
|
|
||||||
let _ = registry.send_to_peer(
|
|
||||||
&requester_key,
|
&requester_key,
|
||||||
PeerMessage::SendMessage(message),
|
|ctx| {
|
||||||
|
crate::message::peer::build_shared_file_list(
|
||||||
|
&shared_directories(ctx),
|
||||||
|
)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
ClientOperation::UserInfoRequested { requester_key } => {
|
||||||
|
Self::reply_to_peer(
|
||||||
|
&client_context,
|
||||||
|
&requester_key,
|
||||||
|
|ctx| {
|
||||||
|
crate::message::peer::build_user_info(
|
||||||
|
ctx.upload_slots as u32,
|
||||||
|
ctx.upload_queue.len() as u32,
|
||||||
|
ctx.has_free_upload_slot(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ClientOperation::FolderContentsRequested {
|
||||||
|
requester_key,
|
||||||
|
token,
|
||||||
|
folder,
|
||||||
|
} => {
|
||||||
|
// ponytail: walks the whole share for one folder, as a
|
||||||
|
// browse does; index by directory if it ever shows up.
|
||||||
|
Self::reply_to_peer(
|
||||||
|
&client_context,
|
||||||
|
&requester_key,
|
||||||
|
|ctx| {
|
||||||
|
let dirs: Vec<_> = shared_directories(ctx)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|dir| dir.name == folder)
|
||||||
|
.collect();
|
||||||
|
crate::message::peer::build_folder_contents(
|
||||||
|
token, &folder, &dirs,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ClientOperation::BrowseResult {
|
ClientOperation::BrowseResult {
|
||||||
username,
|
username,
|
||||||
@@ -842,6 +859,22 @@ impl Client {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Answer a peer with the message `build` derives from the client state.
|
||||||
|
fn reply_to_peer(
|
||||||
|
client_context: &Arc<RwLock<ClientContext>>,
|
||||||
|
requester_key: &str,
|
||||||
|
build: impl FnOnce(&ClientContext) -> crate::message::Message,
|
||||||
|
) {
|
||||||
|
let (registry, message) = match client_context.read_safe() {
|
||||||
|
Ok(ctx) => (ctx.peer_registry.clone(), build(&ctx)),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if let Some(registry) = registry {
|
||||||
|
let _ = registry
|
||||||
|
.send_to_peer(requester_key, PeerMessage::SendMessage(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sweep_expired_connects(client_context: &Arc<RwLock<ClientContext>>) {
|
fn sweep_expired_connects(client_context: &Arc<RwLock<ClientContext>>) {
|
||||||
let expired = match client_context.write_safe() {
|
let expired = match client_context.write_safe() {
|
||||||
Ok(mut ctx) => ctx.take_expired_connects(Instant::now()),
|
Ok(mut ctx) => ctx.take_expired_connects(Instant::now()),
|
||||||
@@ -852,3 +885,17 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Our shares in the wire form a browse or folder reply carries.
|
||||||
|
fn shared_directories(
|
||||||
|
ctx: &ClientContext,
|
||||||
|
) -> Vec<crate::message::peer::SharedDirectory> {
|
||||||
|
ctx.shares
|
||||||
|
.directories()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, files)| crate::message::peer::SharedDirectory {
|
||||||
|
name,
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::{
|
use super::{
|
||||||
Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap,
|
Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap,
|
||||||
Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage,
|
Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage,
|
||||||
SoulseekRs, info, md5, sleep,
|
SoulseekRs, info, next_search_token, sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
@@ -62,8 +62,7 @@ impl Client {
|
|||||||
let Some(handle) = &self.server_handle else {
|
let Some(handle) = &self.server_handle else {
|
||||||
return Err(SoulseekRs::NotConnected);
|
return Err(SoulseekRs::NotConnected);
|
||||||
};
|
};
|
||||||
let hash = md5::md5(query);
|
let token = next_search_token();
|
||||||
let token = u32::from_str_radix(&hash[0..5], 16)?;
|
|
||||||
|
|
||||||
self.context.write_safe()?.searches.insert(
|
self.context.write_safe()?.searches.insert(
|
||||||
query.to_string(),
|
query.to_string(),
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ fn build_search_response_matches_shares_and_echoes_token() {
|
|||||||
std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap();
|
std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap();
|
||||||
let shares = Shares::scan(&dir).unwrap();
|
let shares = Shares::scan(&dir).unwrap();
|
||||||
|
|
||||||
let response = build_search_response(&shares, "me", 99, "xyzzy")
|
let response =
|
||||||
|
build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0)
|
||||||
.expect("a matching share yields a response");
|
.expect("a matching share yields a response");
|
||||||
let mut decoded =
|
let mut decoded =
|
||||||
crate::message::Message::new_with_data(response.get_buffer());
|
crate::message::Message::new_with_data(response.get_buffer());
|
||||||
@@ -170,7 +171,10 @@ fn build_search_response_matches_shares_and_echoes_token() {
|
|||||||
assert_eq!(result.token, 99);
|
assert_eq!(result.token, 99);
|
||||||
assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy")));
|
assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy")));
|
||||||
|
|
||||||
assert!(build_search_response(&shares, "me", 1, "nomatch").is_none());
|
assert!(
|
||||||
|
build_search_response(&shares, "me", 1, "nomatch", true, 0, 0)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ impl ClientContext {
|
|||||||
mut next_token: impl FnMut() -> u32,
|
mut next_token: impl FnMut() -> u32,
|
||||||
) -> (Option<PeerRegistry>, Vec<UploadOffer>) {
|
) -> (Option<PeerRegistry>, Vec<UploadOffer>) {
|
||||||
let mut offers = Vec::new();
|
let mut offers = Vec::new();
|
||||||
while self.uploads_in_flight() < self.upload_slots {
|
while self.has_free_upload_slot() {
|
||||||
let Some(index) = next_to_serve(&self.upload_queue) else {
|
let Some(index) = next_to_serve(&self.upload_queue) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
@@ -190,6 +190,10 @@ impl ClientContext {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_free_upload_slot(&self) -> bool {
|
||||||
|
self.uploads_in_flight() < self.upload_slots
|
||||||
|
}
|
||||||
|
|
||||||
/// The peers still waiting, in the order they will be served, each carrying
|
/// The peers still waiting, in the order they will be served, each carrying
|
||||||
/// its own 1-based place.
|
/// its own 1-based place.
|
||||||
pub(super) fn queued_uploads(&self) -> Vec<UploadInfo> {
|
pub(super) fn queued_uploads(&self) -> Vec<UploadInfo> {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use super::{
|
use super::{
|
||||||
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, PeerMessage,
|
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, PeerMessage,
|
||||||
RwLock, RwLockExt, collect_failed_tokens, error, next_upload_token, thread,
|
RwLock, RwLockExt, ServerMessage, collect_failed_tokens, error,
|
||||||
|
next_upload_token, thread,
|
||||||
};
|
};
|
||||||
|
use crate::message::server::MessageFactory;
|
||||||
use crate::types::UploadStatus;
|
use crate::types::UploadStatus;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64};
|
use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||||
|
|
||||||
@@ -145,8 +147,9 @@ impl Client {
|
|||||||
&bytes_sent,
|
&bytes_sent,
|
||||||
&cancel,
|
&cancel,
|
||||||
);
|
);
|
||||||
|
let streamed = result.as_ref().ok().copied();
|
||||||
let status = match &result {
|
let status = match &result {
|
||||||
Ok(()) => UploadStatus::Completed,
|
Ok(_) => UploadStatus::Completed,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
|
||||||
UploadStatus::Cancelled
|
UploadStatus::Cancelled
|
||||||
}
|
}
|
||||||
@@ -155,10 +158,26 @@ impl Client {
|
|||||||
UploadStatus::Failed(e.to_string())
|
UploadStatus::Failed(e.to_string())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let mut report = None;
|
||||||
if let Ok(mut ctx) = context.write_safe()
|
if let Ok(mut ctx) = context.write_safe()
|
||||||
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
|
&& let Some(upload) = ctx.active_uploads.get_mut(&token)
|
||||||
{
|
{
|
||||||
|
let secs = upload.started.elapsed().as_secs_f64();
|
||||||
upload.status = status;
|
upload.status = status;
|
||||||
|
if let Some(bytes) = streamed
|
||||||
|
&& secs > 0.0
|
||||||
|
{
|
||||||
|
let speed = (bytes as f64 / secs) as u32;
|
||||||
|
ctx.last_upload_speed = speed;
|
||||||
|
report = ctx.server_sender.clone().map(|s| (s, speed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The server folds each finished upload's rate into the average
|
||||||
|
// speed it shows other users for us.
|
||||||
|
if let Some((server, speed)) = report {
|
||||||
|
let _ = server.send(ServerMessage::SendMessage(
|
||||||
|
MessageFactory::build_send_upload_speed(speed),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// The slot this transfer held is free now, so whoever is next in
|
// The slot this transfer held is free now, so whoever is next in
|
||||||
// line gets it without waiting for another request to arrive.
|
// line gets it without waiting for another request to arrive.
|
||||||
|
|||||||
@@ -37,3 +37,40 @@ impl<Op> MessageDispatcher<Op> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::message::MessageHandler;
|
||||||
|
use std::sync::mpsc::channel;
|
||||||
|
|
||||||
|
struct Echo(u32);
|
||||||
|
|
||||||
|
impl MessageHandler<u32> for Echo {
|
||||||
|
fn get_code(&self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn handle(&self, _: &mut Message, sender: Sender<u32>) {
|
||||||
|
let _ = sender.send(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn framed(code: u32) -> Message {
|
||||||
|
Message::new_with_data(Message::new().write_int32(code).get_buffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_code_above_255_is_not_confused_with_its_low_byte() {
|
||||||
|
let (sender, received) = channel();
|
||||||
|
let mut handlers = Handlers::new();
|
||||||
|
handlers.register_handler(Echo(1));
|
||||||
|
handlers.register_handler(Echo(1001));
|
||||||
|
let dispatcher =
|
||||||
|
MessageDispatcher::new("test".to_string(), sender, handlers);
|
||||||
|
|
||||||
|
dispatcher.dispatch(&mut framed(257));
|
||||||
|
dispatcher.dispatch(&mut framed(1001));
|
||||||
|
|
||||||
|
assert_eq!(received.try_iter().collect::<Vec<_>>(), [1001]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use crate::message::Message;
|
|||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
pub trait MessageHandler<Op>: Send {
|
pub trait MessageHandler<Op>: Send {
|
||||||
fn get_code(&self) -> u8;
|
fn get_code(&self) -> u32;
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<Op>);
|
fn handle(&self, message: &mut Message, sender: Sender<Op>);
|
||||||
}
|
}
|
||||||
pub struct Handlers<Op> {
|
pub struct Handlers<Op> {
|
||||||
handlers: HashMap<u8, Box<dyn MessageHandler<Op> + Send>>,
|
handlers: HashMap<u32, Box<dyn MessageHandler<Op> + Send>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Op> Default for Handlers<Op> {
|
impl<Op> Default for Handlers<Op> {
|
||||||
@@ -35,7 +35,7 @@ impl<Op> Handlers<Op> {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_handler(
|
pub fn get_handler(
|
||||||
&self,
|
&self,
|
||||||
code: u8,
|
code: u32,
|
||||||
) -> Option<&(dyn MessageHandler<Op> + Send)> {
|
) -> Option<&(dyn MessageHandler<Op> + Send)> {
|
||||||
self.handlers.get(&code).map(|v| &**v)
|
self.handlers.get(&code).map(|v| &**v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,21 +51,19 @@ impl Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_message_code_u32(&self) -> u32 {
|
pub fn get_message_code(&self) -> u32 {
|
||||||
if self.data.len() < 8 {
|
if self.data.len() < 8 {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
self.data[4..8].try_into().map_or(0, u32::from_le_bytes)
|
self.data[4..8].try_into().map_or(0, u32::from_le_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one-byte code of a peer init frame (PierceFirewall 0, PeerInit 1),
|
||||||
|
/// the only messages whose code is not four bytes wide.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_message_code(&self) -> u8 {
|
pub fn get_init_code(&self) -> u8 {
|
||||||
if self.data.len() <= 4 {
|
self.data.get(4).copied().unwrap_or(0)
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
self.data[4]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -254,6 +252,7 @@ impl Message {
|
|||||||
100 => Ok("AcceptChildren"),
|
100 => Ok("AcceptChildren"),
|
||||||
102 => Ok("PossibleParents"),
|
102 => Ok("PossibleParents"),
|
||||||
104 => Ok("WishlistInterval"),
|
104 => Ok("WishlistInterval"),
|
||||||
|
121 => Ok("SendUploadSpeed"),
|
||||||
160 => Ok("ExcludedSearchPhrases"),
|
160 => Ok("ExcludedSearchPhrases"),
|
||||||
1001 => Ok("CantConnectToPeer"),
|
1001 => Ok("CantConnectToPeer"),
|
||||||
_ => Err(Error(format!("Unknown server message code: {code}"))),
|
_ => Err(Error(format!("Unknown server message code: {code}"))),
|
||||||
@@ -388,10 +387,19 @@ fn read_string_with_missing_length_prefix_does_not_panic() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_message_code_on_short_message_does_not_panic() {
|
fn get_message_code_on_short_message_does_not_panic() {
|
||||||
// A zero-length frame drains to exactly the 4-byte length prefix, leaving
|
// A zero-length frame drains to exactly the 4-byte length prefix, leaving
|
||||||
// no room for a message code at data[4].
|
// no room for a code after it.
|
||||||
let msg = Message::new_with_data(vec![0, 0, 0, 0]);
|
let msg = Message::new_with_data(vec![0, 0, 0, 0]);
|
||||||
assert_eq!(msg.get_message_code(), 0);
|
assert_eq!(msg.get_message_code(), 0);
|
||||||
assert_eq!(msg.get_message_code_u32(), 0);
|
assert_eq!(msg.get_init_code(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_init_frame_reads_its_one_byte_code() {
|
||||||
|
let init = Message::new_with_data(
|
||||||
|
Message::new().write_int8(1).write_string("ab").get_buffer(),
|
||||||
|
);
|
||||||
|
assert_eq!(init.get_init_code(), 1);
|
||||||
|
assert_ne!(init.get_message_code(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::message::{Message, MessageHandler};
|
use crate::message::{Message, MessageHandler};
|
||||||
use crate::peer::PeerMessage;
|
use crate::peer::PeerMessage;
|
||||||
use crate::types::SearchResult;
|
use crate::types::SearchResult;
|
||||||
use crate::utils::zlib::compress_stored;
|
use crate::utils::zlib::deflate;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
/// A borrowed view of one file to advertise in a search response, kept
|
/// A borrowed view of one file to advertise in a search response, kept
|
||||||
@@ -22,6 +22,7 @@ pub fn build_file_search_response(
|
|||||||
files: &[FileEntry],
|
files: &[FileEntry],
|
||||||
slots: u8,
|
slots: u8,
|
||||||
speed: u32,
|
speed: u32,
|
||||||
|
queue_length: u32,
|
||||||
) -> Message {
|
) -> Message {
|
||||||
let mut payload = Message::new();
|
let mut payload = Message::new();
|
||||||
payload
|
payload
|
||||||
@@ -39,9 +40,12 @@ pub fn build_file_search_response(
|
|||||||
payload.write_int32(code).write_int32(value);
|
payload.write_int32(code).write_int32(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload.write_int8(slots).write_int32(speed).write_int32(0); // free upload slots / queue length (well-formed trailer)
|
payload
|
||||||
|
.write_int8(slots)
|
||||||
|
.write_int32(speed)
|
||||||
|
.write_int32(queue_length);
|
||||||
|
|
||||||
let compressed = compress_stored(&payload.get_data());
|
let compressed = deflate(&payload.get_data());
|
||||||
Message::new()
|
Message::new()
|
||||||
.write_int32(9)
|
.write_int32(9)
|
||||||
.write_raw_bytes(compressed)
|
.write_raw_bytes(compressed)
|
||||||
@@ -50,7 +54,7 @@ pub fn build_file_search_response(
|
|||||||
|
|
||||||
pub struct FileSearchResponse;
|
pub struct FileSearchResponse;
|
||||||
impl MessageHandler<PeerMessage> for FileSearchResponse {
|
impl MessageHandler<PeerMessage> for FileSearchResponse {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
9
|
9
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
@@ -112,7 +116,7 @@ fn build_file_search_response_roundtrips_through_the_decoder() {
|
|||||||
attribs: &[],
|
attribs: &[],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let message = build_file_search_response("e2e_sharer", 42, &files, 1, 0);
|
let message = build_file_search_response("e2e_sharer", 42, &files, 1, 0, 0);
|
||||||
|
|
||||||
// Decode via the exact production decoder used for real peer responses:
|
// Decode via the exact production decoder used for real peer responses:
|
||||||
// the dispatcher positions the pointer at 8 (past length + code).
|
// the dispatcher positions the pointer at 8 (past length + code).
|
||||||
@@ -132,3 +136,18 @@ fn build_file_search_response_roundtrips_through_the_decoder() {
|
|||||||
assert!(result.files[1].attribs.is_empty());
|
assert!(result.files[1].attribs.is_empty());
|
||||||
assert_eq!(result.slots, 1);
|
assert_eq!(result.slots, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_trailer_carries_slots_speed_and_queue_length() {
|
||||||
|
let message = build_file_search_response("me", 1, &[], 0, 900, 7);
|
||||||
|
let mut body = Message::new_with_data(
|
||||||
|
crate::utils::zlib::inflate(&message.get_data()[4..]).unwrap(),
|
||||||
|
);
|
||||||
|
body.read_string();
|
||||||
|
body.read_int32();
|
||||||
|
assert_eq!(body.read_int32(), 0, "no files");
|
||||||
|
assert_eq!(
|
||||||
|
(body.read_int8(), body.read_int32(), body.read_int32()),
|
||||||
|
(0, 900, 7)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! `FolderContentsRequest` (peer code 36) and its reply (code 37): one folder
|
||||||
|
//! of a share in the listing form code 5 uses, which is what "download
|
||||||
|
//! folder" in other clients asks for.
|
||||||
|
|
||||||
|
use crate::message::peer::SharedDirectory;
|
||||||
|
use crate::message::peer::shared_file_list::{
|
||||||
|
decompress_body, read_directories, write_directories,
|
||||||
|
};
|
||||||
|
use crate::message::{Message, MessageHandler};
|
||||||
|
use crate::peer::PeerMessage;
|
||||||
|
use crate::utils::zlib::deflate;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
pub struct FolderContentsRequest;
|
||||||
|
impl MessageHandler<PeerMessage> for FolderContentsRequest {
|
||||||
|
fn get_code(&self) -> u32 {
|
||||||
|
36
|
||||||
|
}
|
||||||
|
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
|
let token = message.read_int32();
|
||||||
|
let folder = message.read_string();
|
||||||
|
let _ =
|
||||||
|
sender.send(PeerMessage::FolderContentsRequested { token, folder });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `FolderContentsResponse` (peer code 37).
|
||||||
|
#[must_use]
|
||||||
|
pub fn build_folder_contents(
|
||||||
|
token: u32,
|
||||||
|
folder: &str,
|
||||||
|
dirs: &[SharedDirectory],
|
||||||
|
) -> Message {
|
||||||
|
let mut payload = Message::new();
|
||||||
|
payload.write_int32(token).write_string(folder);
|
||||||
|
write_directories(&mut payload, dirs);
|
||||||
|
Message::new()
|
||||||
|
.write_int32(37)
|
||||||
|
.write_raw_bytes(deflate(&payload.get_data()))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `FolderContentsResponse` positioned at its compressed blob, or
|
||||||
|
/// `None` when it does not inflate.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_folder_contents(
|
||||||
|
message: &mut Message,
|
||||||
|
) -> Option<(u32, String, Vec<SharedDirectory>)> {
|
||||||
|
let mut body = decompress_body(message)?;
|
||||||
|
let token = body.read_int32();
|
||||||
|
let folder = body.read_string();
|
||||||
|
Some((token, folder, read_directories(&mut body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_listing_round_trips() {
|
||||||
|
let dirs = vec![SharedDirectory {
|
||||||
|
name: "music\\album".to_string(),
|
||||||
|
files: vec![("one.flac".to_string(), 40), ("two.flac".to_string(), 50)],
|
||||||
|
}];
|
||||||
|
let built = build_folder_contents(7, "music\\album", &dirs);
|
||||||
|
let mut message = Message::new_with_data(built.get_data());
|
||||||
|
message.set_pointer(4);
|
||||||
|
assert_eq!(
|
||||||
|
parse_folder_contents(&mut message),
|
||||||
|
Some((7, "music\\album".to_string(), dirs))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use std::sync::mpsc::Sender;
|
|||||||
/// owns the shares) builds the real SharedFileListResponse in reply.
|
/// owns the shares) builds the real SharedFileListResponse in reply.
|
||||||
pub struct GetShareFileList;
|
pub struct GetShareFileList;
|
||||||
impl MessageHandler<PeerMessage> for GetShareFileList {
|
impl MessageHandler<PeerMessage> for GetShareFileList {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
4
|
4
|
||||||
}
|
}
|
||||||
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
|
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod file_search_response;
|
mod file_search_response;
|
||||||
|
mod folder_contents;
|
||||||
mod get_share_file_list;
|
mod get_share_file_list;
|
||||||
mod peer_init;
|
mod peer_init;
|
||||||
mod place_in_queue_request;
|
mod place_in_queue_request;
|
||||||
@@ -9,11 +10,15 @@ mod transfer_request;
|
|||||||
mod transfer_response;
|
mod transfer_response;
|
||||||
mod upload_denied;
|
mod upload_denied;
|
||||||
mod upload_failed;
|
mod upload_failed;
|
||||||
|
mod user_info;
|
||||||
|
|
||||||
// Re-export handlers
|
// Re-export handlers
|
||||||
pub use file_search_response::{
|
pub use file_search_response::{
|
||||||
FileEntry, FileSearchResponse, build_file_search_response,
|
FileEntry, FileSearchResponse, build_file_search_response,
|
||||||
};
|
};
|
||||||
|
pub use folder_contents::{
|
||||||
|
FolderContentsRequest, build_folder_contents, parse_folder_contents,
|
||||||
|
};
|
||||||
pub use get_share_file_list::GetShareFileList;
|
pub use get_share_file_list::GetShareFileList;
|
||||||
pub use peer_init::PeerInit;
|
pub use peer_init::PeerInit;
|
||||||
pub use place_in_queue_request::PlaceInQueueRequest;
|
pub use place_in_queue_request::PlaceInQueueRequest;
|
||||||
@@ -27,3 +32,4 @@ pub use transfer_request::TransferRequest;
|
|||||||
pub use transfer_response::TransferResponse;
|
pub use transfer_response::TransferResponse;
|
||||||
pub use upload_denied::UploadDeniedHandler;
|
pub use upload_denied::UploadDeniedHandler;
|
||||||
pub use upload_failed::UploadFailedHandler;
|
pub use upload_failed::UploadFailedHandler;
|
||||||
|
pub use user_info::{UserInfoRequest, build_user_info};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
|
|
||||||
pub struct PeerInit;
|
pub struct PeerInit;
|
||||||
impl MessageHandler<PeerMessage> for PeerInit {
|
impl MessageHandler<PeerMessage> for PeerInit {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct PlaceInQueueRequest;
|
pub struct PlaceInQueueRequest;
|
||||||
|
|
||||||
impl MessageHandler<PeerMessage> for PlaceInQueueRequest {
|
impl MessageHandler<PeerMessage> for PlaceInQueueRequest {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
51
|
51
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct PlaceInQueueResponse;
|
pub struct PlaceInQueueResponse;
|
||||||
|
|
||||||
impl MessageHandler<PeerMessage> for PlaceInQueueResponse {
|
impl MessageHandler<PeerMessage> for PlaceInQueueResponse {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
44
|
44
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct QueueUploadHandler;
|
pub struct QueueUploadHandler;
|
||||||
|
|
||||||
impl MessageHandler<PeerMessage> for QueueUploadHandler {
|
impl MessageHandler<PeerMessage> for QueueUploadHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
43
|
43
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use crate::message::{Message, MessageHandler};
|
use crate::message::{Message, MessageHandler};
|
||||||
use crate::peer::PeerMessage;
|
use crate::peer::PeerMessage;
|
||||||
use crate::utils::zlib::{compress_stored, deflate};
|
use crate::utils::zlib::{deflate, inflate};
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
/// One shared directory and the files directly in it (basename + size).
|
/// One shared directory and the files directly in it (basename + size).
|
||||||
@@ -17,7 +17,7 @@ pub struct SharedDirectory {
|
|||||||
/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them.
|
/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them.
|
||||||
pub struct SharedFileListResponseHandler;
|
pub struct SharedFileListResponseHandler;
|
||||||
impl MessageHandler<PeerMessage> for SharedFileListResponseHandler {
|
impl MessageHandler<PeerMessage> for SharedFileListResponseHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
@@ -30,6 +30,29 @@ impl MessageHandler<PeerMessage> for SharedFileListResponseHandler {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message {
|
pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message {
|
||||||
let mut payload = Message::new();
|
let mut payload = Message::new();
|
||||||
|
write_directories(&mut payload, dirs);
|
||||||
|
payload.write_int32(0); // unknown
|
||||||
|
payload.write_int32(0); // number of private directories
|
||||||
|
|
||||||
|
let compressed = deflate(&payload.get_data());
|
||||||
|
Message::new()
|
||||||
|
.write_int32(5)
|
||||||
|
.write_raw_bytes(compressed)
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the (zlib-compressed) `SharedFileListResponse` payload. `message` must
|
||||||
|
/// be positioned at the compressed blob (the dispatcher sets pointer 8).
|
||||||
|
///
|
||||||
|
/// Returns an empty listing if the payload is malformed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_shared_file_list(message: &mut Message) -> Vec<SharedDirectory> {
|
||||||
|
decompress_body(message)
|
||||||
|
.map_or_else(Vec::new, |mut body| read_directories(&mut body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a directory listing in the form both code 5 and code 37 carry.
|
||||||
|
pub fn write_directories(payload: &mut Message, dirs: &[SharedDirectory]) {
|
||||||
payload.write_int32(dirs.len() as u32);
|
payload.write_int32(dirs.len() as u32);
|
||||||
for dir in dirs {
|
for dir in dirs {
|
||||||
payload
|
payload
|
||||||
@@ -44,35 +67,15 @@ pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message {
|
|||||||
.write_int32(0); // attribute count
|
.write_int32(0); // attribute count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload.write_int32(0); // unknown
|
|
||||||
payload.write_int32(0); // number of private directories
|
|
||||||
|
|
||||||
let compressed = compress_stored(&payload.get_data());
|
|
||||||
Message::new()
|
|
||||||
.write_int32(5)
|
|
||||||
.write_raw_bytes(compressed)
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the (zlib-compressed) `SharedFileListResponse` payload. `message` must
|
/// Read back what [`write_directories`] wrote, stopping early when a hostile
|
||||||
/// be positioned at the compressed blob (the dispatcher sets pointer 8).
|
/// count outruns the payload so a bogus length cannot spin into a huge
|
||||||
///
|
/// allocation loop.
|
||||||
/// Returns an empty listing if the payload is malformed.
|
pub fn read_directories(body: &mut Message) -> Vec<SharedDirectory> {
|
||||||
#[must_use]
|
|
||||||
pub fn parse_shared_file_list(message: &mut Message) -> Vec<SharedDirectory> {
|
|
||||||
let pointer = message.get_pointer();
|
|
||||||
let size = message.get_size();
|
|
||||||
let compressed = message.get_slice(pointer, size);
|
|
||||||
let Ok(data) = deflate(&compressed) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut body = Message::new_with_data(data);
|
|
||||||
let dir_count = body.read_int32();
|
let dir_count = body.read_int32();
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
for _ in 0..dir_count {
|
for _ in 0..dir_count {
|
||||||
// Stop if a hostile count outruns the (decompressed) payload, so a
|
|
||||||
// bogus length can't spin us into a huge allocation loop.
|
|
||||||
if body.get_pointer() >= body.get_size() {
|
if body.get_pointer() >= body.get_size() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -104,12 +107,20 @@ pub fn parse_shared_file_list(message: &mut Message) -> Vec<SharedDirectory> {
|
|||||||
dirs
|
dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inflate a compressed peer payload positioned at the blob.
|
||||||
|
pub fn decompress_body(message: &mut Message) -> Option<Message> {
|
||||||
|
let pointer = message.get_pointer();
|
||||||
|
let size = message.get_size();
|
||||||
|
inflate(&message.get_slice(pointer, size))
|
||||||
|
.ok()
|
||||||
|
.map(Message::new_with_data)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hostile_dir_count_does_not_hang() {
|
fn hostile_dir_count_does_not_hang() {
|
||||||
// A compressed body claiming ~4 billion directories with no data must
|
// A compressed body claiming ~4 billion directories with no data must
|
||||||
// parse to empty promptly rather than looping into an OOM.
|
// parse to empty promptly rather than looping into an OOM.
|
||||||
let compressed =
|
let compressed = crate::utils::zlib::deflate(&u32::MAX.to_le_bytes());
|
||||||
crate::utils::zlib::compress_stored(&u32::MAX.to_le_bytes());
|
|
||||||
let mut message = crate::message::framed(|m| {
|
let mut message = crate::message::framed(|m| {
|
||||||
m.write_raw_bytes(compressed);
|
m.write_raw_bytes(compressed);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
|
|
||||||
pub struct TransferRequest;
|
pub struct TransferRequest;
|
||||||
impl MessageHandler<PeerMessage> for TransferRequest {
|
impl MessageHandler<PeerMessage> for TransferRequest {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
40
|
40
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct TransferResponse;
|
pub struct TransferResponse;
|
||||||
|
|
||||||
impl MessageHandler<PeerMessage> for TransferResponse {
|
impl MessageHandler<PeerMessage> for TransferResponse {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
41
|
41
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
|
|
||||||
pub struct UploadDeniedHandler;
|
pub struct UploadDeniedHandler;
|
||||||
impl MessageHandler<PeerMessage> for UploadDeniedHandler {
|
impl MessageHandler<PeerMessage> for UploadDeniedHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
50
|
50
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
|
|
||||||
pub struct UploadFailedHandler;
|
pub struct UploadFailedHandler;
|
||||||
impl MessageHandler<PeerMessage> for UploadFailedHandler {
|
impl MessageHandler<PeerMessage> for UploadFailedHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
46
|
46
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! `UserInfoRequest` (peer code 15) and its reply (code 16): what a peer says
|
||||||
|
//! about itself when another user opens its profile.
|
||||||
|
|
||||||
|
use crate::message::{Message, MessageHandler};
|
||||||
|
use crate::peer::PeerMessage;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
pub struct UserInfoRequest;
|
||||||
|
impl MessageHandler<PeerMessage> for UserInfoRequest {
|
||||||
|
fn get_code(&self) -> u32 {
|
||||||
|
15
|
||||||
|
}
|
||||||
|
fn handle(&self, _message: &mut Message, sender: Sender<PeerMessage>) {
|
||||||
|
let _ = sender.send(PeerMessage::UserInfoRequested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `UserInfoResponse` (peer code 16) with no description or picture.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build_user_info(
|
||||||
|
upload_slots: u32,
|
||||||
|
queue_size: u32,
|
||||||
|
slots_free: bool,
|
||||||
|
) -> Message {
|
||||||
|
Message::new()
|
||||||
|
.write_int32(16)
|
||||||
|
.write_string("")
|
||||||
|
.write_bool(false)
|
||||||
|
.write_int32(upload_slots)
|
||||||
|
.write_int32(queue_size)
|
||||||
|
.write_bool(slots_free)
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! `AdminMessage` (server code 66): an announcement from the server to
|
||||||
|
//! everyone, surfaced as a private message from the user "server".
|
||||||
|
|
||||||
|
use crate::actor::server_actor::{ServerMessage, UserMessage};
|
||||||
|
use crate::message::{Message, MessageHandler};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub struct AdminMessageHandler;
|
||||||
|
|
||||||
|
impl MessageHandler<ServerMessage> for AdminMessageHandler {
|
||||||
|
fn get_code(&self) -> u32 {
|
||||||
|
66
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as u32);
|
||||||
|
let announcement = UserMessage::new(
|
||||||
|
0,
|
||||||
|
now,
|
||||||
|
"server".to_string(),
|
||||||
|
message.read_string(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let _ =
|
||||||
|
sender.send(ServerMessage::PrivateMessageReceived(announcement));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct ConnectToPeerHandler;
|
pub struct ConnectToPeerHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for ConnectToPeerHandler {
|
impl MessageHandler<ServerMessage> for ConnectToPeerHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
18
|
18
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct ExcludedSearchPhrasesHandler;
|
pub struct ExcludedSearchPhrasesHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
|
impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
160
|
160
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
pub struct FileSearchHandler;
|
pub struct FileSearchHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for FileSearchHandler {
|
impl MessageHandler<ServerMessage> for FileSearchHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
26
|
26
|
||||||
}
|
}
|
||||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct GetPeerAddressHandler;
|
pub struct GetPeerAddressHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for GetPeerAddressHandler {
|
impl MessageHandler<ServerMessage> for GetPeerAddressHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
3
|
3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct JoinRoomHandler;
|
pub struct JoinRoomHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for JoinRoomHandler {
|
impl MessageHandler<ServerMessage> for JoinRoomHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
14
|
14
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct LeaveRoomHandler;
|
pub struct LeaveRoomHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for LeaveRoomHandler {
|
impl MessageHandler<ServerMessage> for LeaveRoomHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
15
|
15
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::message::MessageHandler;
|
|||||||
pub struct LoginHandler;
|
pub struct LoginHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for LoginHandler {
|
impl MessageHandler<ServerMessage> for LoginHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ impl MessageFactory {
|
|||||||
Message::new().write_int32(7).write_string(username).clone()
|
Message::new().write_int32(7).write_string(username).clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the server (code 121) what a finished upload averaged, in bytes
|
||||||
|
/// per second; it folds that into the speed other users see for us.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build_send_upload_speed(bytes_per_second: u32) -> Message {
|
||||||
|
Message::new()
|
||||||
|
.write_int32(121)
|
||||||
|
.write_int32(bytes_per_second)
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the server (code 36) for a user's share statistics.
|
/// Ask the server (code 36) for a user's share statistics.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn build_get_user_stats(username: &str) -> Message {
|
pub fn build_get_user_stats(username: &str) -> Message {
|
||||||
@@ -336,7 +346,7 @@ fn a_transfer_denial_carries_the_token_and_the_reason() {
|
|||||||
let message =
|
let message =
|
||||||
MessageFactory::build_transfer_denial_message(555, "Cancelled");
|
MessageFactory::build_transfer_denial_message(555, "Cancelled");
|
||||||
let mut decoded = Message::new_with_data(message.get_buffer());
|
let mut decoded = Message::new_with_data(message.get_buffer());
|
||||||
assert_eq!(decoded.get_message_code_u32(), 41);
|
assert_eq!(decoded.get_message_code(), 41);
|
||||||
decoded.set_pointer(8);
|
decoded.set_pointer(8);
|
||||||
assert_eq!(decoded.read_int32(), 555);
|
assert_eq!(decoded.read_int32(), 555);
|
||||||
assert!(!decoded.read_bool());
|
assert!(!decoded.read_bool());
|
||||||
@@ -474,3 +484,12 @@ fn test_build_unwatch_user() {
|
|||||||
let expect: Vec<u8> = [6, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
|
let expect: Vec<u8> = [6, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
|
||||||
assert_eq!(expect, message.get_data());
|
assert_eq!(expect, message.get_data());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upload_speed_report_carries_the_rate() {
|
||||||
|
let message = MessageFactory::build_send_upload_speed(900);
|
||||||
|
let mut decoded = Message::new_with_data(message.get_buffer());
|
||||||
|
assert_eq!(decoded.get_message_code(), 121);
|
||||||
|
decoded.set_pointer(8);
|
||||||
|
assert_eq!(decoded.read_int32(), 900);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct MessageUser;
|
pub struct MessageUser;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for MessageUser {
|
impl MessageHandler<ServerMessage> for MessageUser {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
22
|
22
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod admin_message;
|
||||||
mod connect_to_peer;
|
mod connect_to_peer;
|
||||||
mod excluded_search_phrases;
|
mod excluded_search_phrases;
|
||||||
mod file_search;
|
mod file_search;
|
||||||
@@ -19,6 +20,7 @@ mod user_left_room;
|
|||||||
mod watch_user;
|
mod watch_user;
|
||||||
mod wish_list_interval;
|
mod wish_list_interval;
|
||||||
|
|
||||||
|
pub use admin_message::AdminMessageHandler;
|
||||||
pub use connect_to_peer::ConnectToPeerHandler;
|
pub use connect_to_peer::ConnectToPeerHandler;
|
||||||
pub use excluded_search_phrases::ExcludedSearchPhrasesHandler;
|
pub use excluded_search_phrases::ExcludedSearchPhrasesHandler;
|
||||||
pub use file_search::FileSearchHandler;
|
pub use file_search::FileSearchHandler;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
pub struct ParentMinSpeedHandler;
|
pub struct ParentMinSpeedHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for ParentMinSpeedHandler {
|
impl MessageHandler<ServerMessage> for ParentMinSpeedHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
83
|
83
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub struct ParentSpeedRatioHandler;
|
|||||||
|
|
||||||
// The server sends us a speed ratio determining the number of children we can have in the distributed network. The maximum number of children is our upload speed divided by the speed ratio.
|
// The server sends us a speed ratio determining the number of children we can have in the distributed network. The maximum number of children is our upload speed divided by the speed ratio.
|
||||||
impl MessageHandler<ServerMessage> for ParentSpeedRatioHandler {
|
impl MessageHandler<ServerMessage> for ParentSpeedRatioHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
84
|
84
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
pub struct PrivilegedUsersHandler;
|
pub struct PrivilegedUsersHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for PrivilegedUsersHandler {
|
impl MessageHandler<ServerMessage> for PrivilegedUsersHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
69
|
69
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ impl MessageHandler<ServerMessage> for PrivilegedUsersHandler {
|
|||||||
pub struct CheckPrivilegesHandler;
|
pub struct CheckPrivilegesHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for CheckPrivilegesHandler {
|
impl MessageHandler<ServerMessage> for CheckPrivilegesHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
92
|
92
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct ReloggedHandler;
|
pub struct ReloggedHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for ReloggedHandler {
|
impl MessageHandler<ServerMessage> for ReloggedHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
41
|
41
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct RoomListHandler;
|
pub struct RoomListHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for RoomListHandler {
|
impl MessageHandler<ServerMessage> for RoomListHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
64
|
64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct SayChatroomHandler;
|
pub struct SayChatroomHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for SayChatroomHandler {
|
impl MessageHandler<ServerMessage> for SayChatroomHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
13
|
13
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct GetUserStatusHandler;
|
pub struct GetUserStatusHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for GetUserStatusHandler {
|
impl MessageHandler<ServerMessage> for GetUserStatusHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
7
|
7
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ impl MessageHandler<ServerMessage> for GetUserStatusHandler {
|
|||||||
pub struct GetUserStatsHandler;
|
pub struct GetUserStatsHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for GetUserStatsHandler {
|
impl MessageHandler<ServerMessage> for GetUserStatsHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
36
|
36
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct UserJoinedRoomHandler;
|
pub struct UserJoinedRoomHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for UserJoinedRoomHandler {
|
impl MessageHandler<ServerMessage> for UserJoinedRoomHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
16
|
16
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct UserLeftRoomHandler;
|
pub struct UserLeftRoomHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for UserLeftRoomHandler {
|
impl MessageHandler<ServerMessage> for UserLeftRoomHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
17
|
17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use std::sync::mpsc::Sender;
|
|||||||
pub struct WatchUserHandler;
|
pub struct WatchUserHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for WatchUserHandler {
|
impl MessageHandler<ServerMessage> for WatchUserHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
pub struct WishListIntervalHandler;
|
pub struct WishListIntervalHandler;
|
||||||
|
|
||||||
impl MessageHandler<ServerMessage> for WishListIntervalHandler {
|
impl MessageHandler<ServerMessage> for WishListIntervalHandler {
|
||||||
fn get_code(&self) -> u8 {
|
fn get_code(&self) -> u32 {
|
||||||
104
|
104
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ use crate::trace;
|
|||||||
use crate::types::{Download, DownloadStatus};
|
use crate::types::{Download, DownloadStatus};
|
||||||
use crate::utils::path::{PART_SUFFIX, expand_tilde};
|
use crate::utils::path::{PART_SUFFIX, expand_tilde};
|
||||||
|
|
||||||
const READ_BUFFER_SIZE: usize = 8192;
|
/// Read in 64 KiB slices: an eighth of the syscalls of the 8 KiB reads before,
|
||||||
|
/// and what a fast peer fills per read. slskd reads 256 KiB, Nicotine+ ~50 KiB.
|
||||||
|
const READ_BUFFER_SIZE: usize = 64 * 1024;
|
||||||
const CANCEL_POLL: Duration = Duration::from_secs(1);
|
const CANCEL_POLL: Duration = Duration::from_secs(1);
|
||||||
const STALL_DEADLINE: Duration = Duration::from_secs(30);
|
const STALL_DEADLINE: Duration = Duration::from_secs(30);
|
||||||
const PROGRESS_UPDATE_CHUNKS: usize = 15; // ~120KB (15 * 8192 bytes)
|
/// Bytes between progress reports. Counting bytes rather than reads keeps
|
||||||
|
/// the report volume flat however big a read is, and gives a slow peer's
|
||||||
|
/// rate a window wide enough to mean something.
|
||||||
|
const PROGRESS_INTERVAL_BYTES: usize = 128 * 1024;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum DownloadError {
|
pub enum DownloadError {
|
||||||
@@ -351,8 +356,8 @@ impl DownloadPeer {
|
|||||||
client_context: &Arc<RwLock<ClientContext>>,
|
client_context: &Arc<RwLock<ClientContext>>,
|
||||||
download: Option<Download>,
|
download: Option<Download>,
|
||||||
) -> Result<(Download, String), DownloadError> {
|
) -> Result<(Download, String), DownloadError> {
|
||||||
let mut read_buffer = [0u8; READ_BUFFER_SIZE];
|
let mut read_buffer = vec![0u8; READ_BUFFER_SIZE];
|
||||||
let mut chunk_counter = 0usize;
|
let mut bytes_since_last_update = 0usize;
|
||||||
let mut last_update_time = Instant::now();
|
let mut last_update_time = Instant::now();
|
||||||
let mut last_data = Instant::now();
|
let mut last_data = Instant::now();
|
||||||
stream
|
stream
|
||||||
@@ -413,12 +418,10 @@ impl DownloadPeer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
part.write(data)?;
|
part.write(data)?;
|
||||||
chunk_counter += 1;
|
bytes_since_last_update += bytes_read;
|
||||||
|
|
||||||
if chunk_counter.is_multiple_of(PROGRESS_UPDATE_CHUNKS) {
|
if bytes_since_last_update >= PROGRESS_INTERVAL_BYTES {
|
||||||
let elapsed = last_update_time.elapsed().as_secs_f64();
|
let elapsed = last_update_time.elapsed().as_secs_f64();
|
||||||
let bytes_since_last_update =
|
|
||||||
PROGRESS_UPDATE_CHUNKS * READ_BUFFER_SIZE;
|
|
||||||
let speed = if elapsed > 0.0 {
|
let speed = if elapsed > 0.0 {
|
||||||
bytes_since_last_update as f64 / elapsed
|
bytes_since_last_update as f64 / elapsed
|
||||||
} else {
|
} else {
|
||||||
@@ -430,6 +433,7 @@ impl DownloadPeer {
|
|||||||
part.written,
|
part.written,
|
||||||
speed,
|
speed,
|
||||||
);
|
);
|
||||||
|
bytes_since_last_update = 0;
|
||||||
last_update_time = Instant::now();
|
last_update_time = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ fn handle_incoming_connection(
|
|||||||
|
|
||||||
// A firewalled peer brokered through the server connects back with a
|
// A firewalled peer brokered through the server connects back with a
|
||||||
// PierceFirewall (code 0) instead of a PeerInit (code 1).
|
// PierceFirewall (code 0) instead of a PeerInit (code 1).
|
||||||
if message.get_message_code() == 0 {
|
if message.get_init_code() == 0 {
|
||||||
handle_pierce_firewall(
|
handle_pierce_firewall(
|
||||||
message, stream, reader, &context, &peer_ip, peer_port,
|
message, stream, reader, &context, &peer_ip, peer_port,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ fn test_new_from_message2() {
|
|||||||
let mut message = Message::new_with_data(data);
|
let mut message = Message::new_with_data(data);
|
||||||
message.set_pointer(8);
|
message.set_pointer(8);
|
||||||
|
|
||||||
println!("code: {}", message.get_message_code_u32());
|
println!("code: {}", message.get_message_code());
|
||||||
|
|
||||||
let peer = Peer::new_from_message(&mut message).unwrap();
|
let peer = Peer::new_from_message(&mut message).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ use crate::trace;
|
|||||||
/// START_DOWNLOAD offset before we stream the file.
|
/// START_DOWNLOAD offset before we stream the file.
|
||||||
///
|
///
|
||||||
/// `bytes_sent` is updated as the transfer progresses, and setting `cancel`
|
/// `bytes_sent` is updated as the transfer progresses, and setting `cancel`
|
||||||
/// aborts the stream with an [`io::ErrorKind::Interrupted`] error.
|
/// aborts the stream with an [`io::ErrorKind::Interrupted`] error. Returns the
|
||||||
|
/// bytes streamed by this call, which a resumed transfer's prefix is not.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns any I/O error opening the file or talking to the peer.
|
/// Returns any I/O error opening the file or talking to the peer.
|
||||||
@@ -31,7 +32,7 @@ pub fn serve_file(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
bytes_sent: &AtomicU64,
|
bytes_sent: &AtomicU64,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<u64> {
|
||||||
let mut file = File::open(path)?;
|
let mut file = File::open(path)?;
|
||||||
|
|
||||||
let socket = format!("{host}:{port}")
|
let socket = format!("{host}:{port}")
|
||||||
@@ -78,6 +79,7 @@ pub fn serve_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut buffer = vec![0u8; 64 * 1024];
|
let mut buffer = vec![0u8; 64 * 1024];
|
||||||
|
let mut streamed = 0u64;
|
||||||
loop {
|
loop {
|
||||||
if cancel.load(Ordering::Relaxed) {
|
if cancel.load(Ordering::Relaxed) {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
@@ -91,6 +93,7 @@ pub fn serve_file(
|
|||||||
}
|
}
|
||||||
stream.write_all(&buffer[..read])?;
|
stream.write_all(&buffer[..read])?;
|
||||||
bytes_sent.fetch_add(read as u64, Ordering::Relaxed);
|
bytes_sent.fetch_add(read as u64, Ordering::Relaxed);
|
||||||
|
streamed += read as u64;
|
||||||
}
|
}
|
||||||
stream.flush()?;
|
stream.flush()?;
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ pub fn serve_file(
|
|||||||
// a slot cap is time no other peer can use.
|
// a slot cap is time no other peer can use.
|
||||||
stream.shutdown(std::net::Shutdown::Write).ok();
|
stream.shutdown(std::net::Shutdown::Write).ok();
|
||||||
trace!("[upload] served {} to {}:{}", path.display(), host, port);
|
trace!("[upload] served {} to {}:{}", path.display(), host, port);
|
||||||
Ok(())
|
Ok(streamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use std::path::{Path, PathBuf};
|
|||||||
pub struct SharedFile {
|
pub struct SharedFile {
|
||||||
/// Backslash-separated path exposed to peers, e.g. `music\album\song.mp3`.
|
/// Backslash-separated path exposed to peers, e.g. `music\album\song.mp3`.
|
||||||
pub virtual_path: String,
|
pub virtual_path: String,
|
||||||
|
/// `virtual_path` lowercased once at scan time, so answering a search does
|
||||||
|
/// not lowercase every shared path again.
|
||||||
|
search_key: String,
|
||||||
/// The real filesystem path used to serve the bytes.
|
/// The real filesystem path used to serve the bytes.
|
||||||
pub real_path: PathBuf,
|
pub real_path: PathBuf,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
@@ -106,10 +109,7 @@ impl Shares {
|
|||||||
}
|
}
|
||||||
self.files
|
self.files
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| {
|
.filter(|f| terms.iter().all(|t| f.search_key.contains(t.as_str())))
|
||||||
let haystack = f.virtual_path.to_lowercase();
|
|
||||||
terms.iter().all(|t| haystack.contains(t.as_str()))
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +200,7 @@ fn scan_root(
|
|||||||
}
|
}
|
||||||
let virtual_path = virtual_path_for(root_name, root, &path);
|
let virtual_path = virtual_path_for(root_name, root, &path);
|
||||||
files.push(SharedFile {
|
files.push(SharedFile {
|
||||||
|
search_key: virtual_path.to_lowercase(),
|
||||||
virtual_path,
|
virtual_path,
|
||||||
real_path: path,
|
real_path: path,
|
||||||
size: meta.len(),
|
size: meta.len(),
|
||||||
@@ -278,6 +279,20 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mixed_case_path_matches_a_lowercase_query() {
|
||||||
|
let root = std::env::temp_dir()
|
||||||
|
.join(format!("soulseek-shares-case-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(root.join("Live Set")).unwrap();
|
||||||
|
std::fs::write(root.join("Live Set").join("Opening TRACK.flac"), b"x")
|
||||||
|
.unwrap();
|
||||||
|
let shares = Shares::scan(&root).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(shares.search("live opening track").len(), 1);
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_is_case_insensitive_and_multi_term_and() {
|
fn search_is_case_insensitive_and_multi_term_and() {
|
||||||
let root = temp_tree();
|
let root = temp_tree();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{collections::HashMap, sync::mpsc::Sender};
|
use std::{collections::HashMap, sync::mpsc::Sender};
|
||||||
|
|
||||||
use crate::{error::Result, message::Message, utils::zlib::deflate};
|
use crate::{error::Result, message::Message, utils::zlib::inflate};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -84,6 +84,14 @@ pub const MAX_SEARCH_RESPONSES: usize = 500;
|
|||||||
/// matching collections cannot blow past the response cap.
|
/// matching collections cannot blow past the response cap.
|
||||||
pub const MAX_SEARCH_FILES: usize = 10_000;
|
pub const MAX_SEARCH_FILES: usize = 10_000;
|
||||||
|
|
||||||
|
/// Files one reply to a peer's search carries at most.
|
||||||
|
///
|
||||||
|
/// A broad query against a big share would otherwise hand the whole index
|
||||||
|
/// to whoever typed it, on every such search that arrives. Nicotine+ sends
|
||||||
|
/// 300 by default and slskd 500; a searcher with that many sources from one
|
||||||
|
/// peer has plenty.
|
||||||
|
pub const MAX_SEARCH_REPLY_FILES: usize = 300;
|
||||||
|
|
||||||
impl Search {
|
impl Search {
|
||||||
/// Store one peer's response, unless this search already holds enough.
|
/// Store one peer's response, unless this search already holds enough.
|
||||||
///
|
///
|
||||||
@@ -106,7 +114,7 @@ impl SearchResult {
|
|||||||
let pointer = message.get_pointer();
|
let pointer = message.get_pointer();
|
||||||
let size = message.get_size();
|
let size = message.get_size();
|
||||||
let data: Vec<u8> = message.get_slice(pointer, size);
|
let data: Vec<u8> = message.get_slice(pointer, size);
|
||||||
let deflated = deflate(&data)?;
|
let deflated = inflate(&data)?;
|
||||||
let mut message = Message::new_with_data(deflated);
|
let mut message = Message::new_with_data(deflated);
|
||||||
|
|
||||||
let username = message.read_string();
|
let username = message.read_string();
|
||||||
@@ -415,7 +423,7 @@ mod tests {
|
|||||||
body.extend_from_slice(&0u32.to_le_bytes()); // username "" (len 0)
|
body.extend_from_slice(&0u32.to_le_bytes()); // username "" (len 0)
|
||||||
body.extend_from_slice(&7u32.to_le_bytes()); // token
|
body.extend_from_slice(&7u32.to_le_bytes()); // token
|
||||||
body.extend_from_slice(&u32::MAX.to_le_bytes()); // n_files (hostile)
|
body.extend_from_slice(&u32::MAX.to_le_bytes()); // n_files (hostile)
|
||||||
let compressed = crate::utils::zlib::compress_stored(&body);
|
let compressed = crate::utils::zlib::deflate(&body);
|
||||||
let mut message = Message::new_with_data(compressed);
|
let mut message = Message::new_with_data(compressed);
|
||||||
let result = SearchResult::new_from_message(&mut message)
|
let result = SearchResult::new_from_message(&mut message)
|
||||||
.expect("hostile count should parse, not error");
|
.expect("hostile count should parse, not error");
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! TCP keepalive for the server connection.
|
||||||
|
//!
|
||||||
|
//! A NAT that drops an idle mapping leaves the socket looking connected while
|
||||||
|
//! the server can no longer reach us, and nothing ever errors. Probes every
|
||||||
|
//! ten seconds keep the mapping alive and turn a dead path into a read error
|
||||||
|
//! within about half a minute, which is what Nicotine+ does. The server
|
||||||
|
//! stopped answering ServerPing (code 32) long ago, so that is no substitute.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn set_keepalive(stream: &TcpStream) -> io::Result<()> {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
let fd = stream.as_raw_fd();
|
||||||
|
set_option(fd, libc::SOL_SOCKET, libc::SO_KEEPALIVE, 1)?;
|
||||||
|
#[cfg(any(
|
||||||
|
target_vendor = "apple",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "android",
|
||||||
|
target_os = "freebsd",
|
||||||
|
target_os = "netbsd"
|
||||||
|
))]
|
||||||
|
{
|
||||||
|
set_option(fd, libc::IPPROTO_TCP, IDLE_SECONDS_OPTION, 10)?;
|
||||||
|
set_option(fd, libc::IPPROTO_TCP, libc::TCP_KEEPINTVL, 2)?;
|
||||||
|
set_option(fd, libc::IPPROTO_TCP, libc::TCP_KEEPCNT, 10)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
const IDLE_SECONDS_OPTION: libc::c_int = libc::TCP_KEEPALIVE;
|
||||||
|
#[cfg(any(
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "android",
|
||||||
|
target_os = "freebsd",
|
||||||
|
target_os = "netbsd"
|
||||||
|
))]
|
||||||
|
const IDLE_SECONDS_OPTION: libc::c_int = libc::TCP_KEEPIDLE;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_option(
|
||||||
|
fd: libc::c_int,
|
||||||
|
level: libc::c_int,
|
||||||
|
name: libc::c_int,
|
||||||
|
value: libc::c_int,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
// SAFETY: `value` outlives the call and its exact size travels with it.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
fd,
|
||||||
|
level,
|
||||||
|
name,
|
||||||
|
(&raw const value).cast(),
|
||||||
|
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: Windows wants WSAIoctl(SIO_KEEPALIVE_VALS); wire it when the
|
||||||
|
// daemon ships there.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn set_keepalive(_stream: &TcpStream) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
pub fn keepalive_enabled(stream: &TcpStream) -> bool {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
let mut value: libc::c_int = 0;
|
||||||
|
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
|
||||||
|
// SAFETY: `value` and `len` outlive the call and `len` bounds the write.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
stream.as_raw_fd(),
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_KEEPALIVE,
|
||||||
|
(&raw mut value).cast(),
|
||||||
|
&raw mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
rc == 0 && value != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
#[test]
|
||||||
|
fn keepalive_is_off_until_asked_for() {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||||
|
assert!(!keepalive_enabled(&stream));
|
||||||
|
set_keepalive(&stream).unwrap();
|
||||||
|
assert!(keepalive_enabled(&stream));
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use std::fs::{File, OpenOptions};
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Mutex, Once,
|
Mutex, Once,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
@@ -16,7 +16,23 @@ pub enum LogLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static INIT: Once = Once::new();
|
static INIT: Once = Once::new();
|
||||||
static mut LOG_LEVEL: LogLevel = LogLevel::Warn;
|
static LOG_LEVEL: AtomicU8 = AtomicU8::new(LogLevel::Warn as u8);
|
||||||
|
|
||||||
|
/// Change the level at runtime, from any thread.
|
||||||
|
pub fn set_log_level(level: LogLevel) {
|
||||||
|
LOG_LEVEL.store(level as u8, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn log_level() -> LogLevel {
|
||||||
|
match LOG_LEVEL.load(Ordering::Relaxed) {
|
||||||
|
0 => LogLevel::Error,
|
||||||
|
2 => LogLevel::Info,
|
||||||
|
3 => LogLevel::Debug,
|
||||||
|
4 => LogLevel::Trace,
|
||||||
|
_ => LogLevel::Warn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static BUFFER: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
static BUFFER: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
static BUFFERING: AtomicBool = AtomicBool::new(false);
|
static BUFFERING: AtomicBool = AtomicBool::new(false);
|
||||||
@@ -29,15 +45,13 @@ pub fn init() {
|
|||||||
.unwrap_or_else(|_| "WARN".to_string())
|
.unwrap_or_else(|_| "WARN".to_string())
|
||||||
.to_uppercase();
|
.to_uppercase();
|
||||||
|
|
||||||
unsafe {
|
set_log_level(match level.as_str() {
|
||||||
LOG_LEVEL = match level.as_str() {
|
|
||||||
"ERROR" => LogLevel::Error,
|
"ERROR" => LogLevel::Error,
|
||||||
"INFO" => LogLevel::Info,
|
"INFO" => LogLevel::Info,
|
||||||
"DEBUG" | "VERBOSE" => LogLevel::Debug, // Map VERBOSE to DEBUG
|
"DEBUG" | "VERBOSE" => LogLevel::Debug,
|
||||||
"TRACE" => LogLevel::Trace,
|
"TRACE" => LogLevel::Trace,
|
||||||
_ => LogLevel::Warn, // "WARN" or default
|
_ => LogLevel::Warn,
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize log file if LOG_FILE env var is set
|
// Initialize log file if LOG_FILE env var is set
|
||||||
if let Ok(log_file_path) = env::var("LOG_FILE") {
|
if let Ok(log_file_path) = env::var("LOG_FILE") {
|
||||||
@@ -85,8 +99,8 @@ fn has_log_file() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn log(level: LogLevel, message: &str) {
|
pub fn log(level: LogLevel, message: &str) {
|
||||||
unsafe {
|
if level <= log_level() {
|
||||||
if level <= LOG_LEVEL {
|
{
|
||||||
let (name, colour) = match level {
|
let (name, colour) = match level {
|
||||||
LogLevel::Error => ("ERROR", "\x1b[31m"), // Red
|
LogLevel::Error => ("ERROR", "\x1b[31m"), // Red
|
||||||
LogLevel::Warn => ("WARN", "\x1b[33m"), // Yellow
|
LogLevel::Warn => ("WARN", "\x1b[33m"), // Yellow
|
||||||
@@ -239,7 +253,16 @@ macro_rules! trace {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{LogSink, choose_sink};
|
use super::{LogLevel, LogSink, choose_sink, log_level, set_log_level};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_level_can_be_changed_while_running() {
|
||||||
|
let before = log_level();
|
||||||
|
set_log_level(LogLevel::Trace);
|
||||||
|
assert_eq!(log_level(), LogLevel::Trace);
|
||||||
|
set_log_level(before);
|
||||||
|
assert_eq!(log_level(), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_configured_file_bypasses_buffering_so_lines_are_not_duplicated() {
|
fn a_configured_file_bypasses_buffering_so_lines_are_not_duplicated() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
pub mod logger;
|
pub mod logger;
|
||||||
|
pub(crate) mod keepalive;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
pub mod md5;
|
pub mod md5;
|
||||||
pub mod path;
|
pub mod path;
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ impl BitReader {
|
|||||||
|
|
||||||
use crate::error::{Result, SoulseekRs};
|
use crate::error::{Result, SoulseekRs};
|
||||||
|
|
||||||
pub fn deflate(input: &[u8]) -> Result<Vec<u8>> {
|
/// Inflate a zlib stream, checking its adler32 trailer.
|
||||||
|
pub fn inflate(input: &[u8]) -> Result<Vec<u8>> {
|
||||||
let mut r = BitReader::new(input.to_vec());
|
let mut r = BitReader::new(input.to_vec());
|
||||||
let cmf = r.read_byte()?;
|
let cmf = r.read_byte()?;
|
||||||
let cm = cmf & 15; // Compression method
|
let cm = cmf & 15; // Compression method
|
||||||
@@ -83,37 +84,16 @@ pub fn deflate(input: &[u8]) -> Result<Vec<u8>> {
|
|||||||
"preset dictionary not supported".to_string(),
|
"preset dictionary not supported".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let out = inflate(&mut r).map_err(SoulseekRs::CompressionError)?; // decompress DEFLATE data
|
let out = inflate_blocks(&mut r).map_err(SoulseekRs::CompressionError)?;
|
||||||
let _adler32 = r.read_bytes(4)?; // Adler-32 checksum (for this exercise, we ignore it)
|
// `read_bytes` assembles little-endian; the zlib trailer is big-endian.
|
||||||
|
if r.read_bytes(4)?.swap_bytes() != adler32(&out) {
|
||||||
|
return Err(SoulseekRs::CompressionError(
|
||||||
|
"adler32 checksum failed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compress `data` into a valid zlib stream using only STORED blocks.
|
|
||||||
///
|
|
||||||
/// No real compressor is needed: standard zlib decoders (and [`deflate`] above)
|
|
||||||
/// accept a `0x78 0x01` header, one or more uncompressed DEFLATE "stored" blocks
|
|
||||||
/// of at most `0xFFFF` bytes, then a big-endian adler32 checksum.
|
|
||||||
#[must_use]
|
|
||||||
pub fn compress_stored(data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = vec![0x78, 0x01];
|
|
||||||
if data.is_empty() {
|
|
||||||
// A single empty, final stored block: BFINAL=1, LEN=0, NLEN=0xFFFF.
|
|
||||||
out.extend_from_slice(&[0x01, 0x00, 0x00, 0xFF, 0xFF]);
|
|
||||||
} else {
|
|
||||||
let mut chunks = data.chunks(0xFFFF).peekable();
|
|
||||||
while let Some(chunk) = chunks.next() {
|
|
||||||
// BFINAL in bit 0, BTYPE=00 (stored) in bits 1-2.
|
|
||||||
out.push(u8::from(chunks.peek().is_none()));
|
|
||||||
let len = chunk.len() as u16;
|
|
||||||
out.extend_from_slice(&len.to_le_bytes());
|
|
||||||
out.extend_from_slice(&(!len).to_le_bytes());
|
|
||||||
out.extend_from_slice(chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.extend_from_slice(&adler32(data).to_be_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RFC 1950 adler32 checksum.
|
/// RFC 1950 adler32 checksum.
|
||||||
fn adler32(data: &[u8]) -> u32 {
|
fn adler32(data: &[u8]) -> u32 {
|
||||||
let mut a: u32 = 1;
|
let mut a: u32 = 1;
|
||||||
@@ -125,7 +105,7 @@ fn adler32(data: &[u8]) -> u32 {
|
|||||||
(b << 16) | a
|
(b << 16) | a
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inflate(r: &mut BitReader) -> std::result::Result<Vec<u8>, String> {
|
fn inflate_blocks(r: &mut BitReader) -> std::result::Result<Vec<u8>, String> {
|
||||||
let mut bfinal = 0;
|
let mut bfinal = 0;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
while bfinal == 0 {
|
while bfinal == 0 {
|
||||||
@@ -392,11 +372,234 @@ fn inflate_block_fixed(
|
|||||||
inflate_block_data(r, &literal_length_tree, &distance_tree, o)
|
inflate_block_data(r, &literal_length_tree, &distance_tree, o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BitWriter {
|
||||||
|
out: Vec<u8>,
|
||||||
|
bit: u32,
|
||||||
|
held: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitWriter {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
out: vec![0x78, 0x01],
|
||||||
|
bit: 0,
|
||||||
|
held: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra bits, least significant first.
|
||||||
|
fn bits(&mut self, value: u32, count: u32) {
|
||||||
|
self.held |= value << self.bit;
|
||||||
|
self.bit += count;
|
||||||
|
while self.bit >= 8 {
|
||||||
|
self.out.push((self.held & 0xFF) as u8);
|
||||||
|
self.held >>= 8;
|
||||||
|
self.bit -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Huffman code, most significant bit first.
|
||||||
|
fn code(&mut self, value: u32, count: u32) {
|
||||||
|
for i in (0..count).rev() {
|
||||||
|
self.bits((value >> i) & 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(mut self, trailer: u32) -> Vec<u8> {
|
||||||
|
if self.bit > 0 {
|
||||||
|
self.out.push((self.held & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
self.out.extend_from_slice(&trailer.to_be_bytes());
|
||||||
|
self.out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_MATCH: usize = 3;
|
||||||
|
const MAX_MATCH: usize = 258;
|
||||||
|
const WINDOW: usize = 32_768;
|
||||||
|
const MATCH_TRIES: usize = 128;
|
||||||
|
/// A match this long is good enough to stop walking the chain.
|
||||||
|
const NICE_MATCH: usize = 64;
|
||||||
|
|
||||||
|
/// Hash table size for `len` bytes: a search reply of a few hundred bytes
|
||||||
|
/// must not zero 256 KiB of table, a listing of megabytes wants all of it.
|
||||||
|
fn hash_bits(len: usize) -> u32 {
|
||||||
|
len.max(2).next_power_of_two().trailing_zeros().clamp(8, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed Huffman code for a literal or length symbol (RFC 1951 3.2.6).
|
||||||
|
fn symbol(w: &mut BitWriter, symbol: usize) {
|
||||||
|
let symbol = symbol as u32;
|
||||||
|
match symbol {
|
||||||
|
0..=143 => w.code(0x30 + symbol, 8),
|
||||||
|
144..=255 => w.code(0x190 + symbol - 144, 9),
|
||||||
|
256..=279 => w.code(symbol - 256, 7),
|
||||||
|
_ => w.code(0xC0 + symbol - 280, 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_match(w: &mut BitWriter, length: usize, distance: usize) {
|
||||||
|
let l = LENGTH_BASE
|
||||||
|
.iter()
|
||||||
|
.rposition(|&base| base as usize <= length)
|
||||||
|
.unwrap_or(0);
|
||||||
|
symbol(w, 257 + l);
|
||||||
|
w.bits(length as u32 - LENGTH_BASE[l], LENGTH_EXTRA_BITS[l] as u32);
|
||||||
|
let d = DISTANCE_BASE
|
||||||
|
.iter()
|
||||||
|
.rposition(|&base| base as usize <= distance)
|
||||||
|
.unwrap_or(0);
|
||||||
|
w.code(d as u32, 5);
|
||||||
|
w.bits(
|
||||||
|
distance as u32 - DISTANCE_BASE[d],
|
||||||
|
DISTANCE_EXTRA_BITS[d] as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash(data: &[u8], at: usize, bits: u32) -> usize {
|
||||||
|
let three = u32::from(data[at]) << 16
|
||||||
|
| u32::from(data[at + 1]) << 8
|
||||||
|
| u32::from(data[at + 2]);
|
||||||
|
(three.wrapping_mul(2_654_435_761) >> (32 - bits)) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chain the three bytes at `at` into the match finder.
|
||||||
|
fn remember(head: &mut [usize], prev: &mut [usize], data: &[u8], at: usize) {
|
||||||
|
if at + MIN_MATCH > data.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let h = hash(data, at, head.len().trailing_zeros());
|
||||||
|
prev[at] = head[h];
|
||||||
|
head[h] = at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deflate `data` into a zlib stream.
|
||||||
|
///
|
||||||
|
/// One fixed-Huffman block over an LZ77 hash-chain match finder, which is
|
||||||
|
/// what shrinks a listing's repeated path prefixes. ponytail: fixed codes, not
|
||||||
|
/// dynamic; listings compress 5-10x either way and this is a tenth of the code.
|
||||||
|
#[must_use]
|
||||||
|
pub fn deflate(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut w = BitWriter::new();
|
||||||
|
w.bits(1, 1); // final block
|
||||||
|
w.bits(1, 2); // fixed Huffman codes
|
||||||
|
let bits = hash_bits(data.len());
|
||||||
|
let mut head = vec![usize::MAX; 1 << bits];
|
||||||
|
let mut prev = vec![usize::MAX; data.len()];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < data.len() {
|
||||||
|
let mut best = (0, 0);
|
||||||
|
if i + MIN_MATCH <= data.len() {
|
||||||
|
let h = hash(data, i, bits);
|
||||||
|
let mut candidate = head[h];
|
||||||
|
let mut tries = MATCH_TRIES;
|
||||||
|
let max = MAX_MATCH.min(data.len() - i);
|
||||||
|
while candidate != usize::MAX
|
||||||
|
&& tries > 0
|
||||||
|
&& i - candidate <= WINDOW
|
||||||
|
{
|
||||||
|
let len = (0..max)
|
||||||
|
.take_while(|&k| data[candidate + k] == data[i + k])
|
||||||
|
.count();
|
||||||
|
if len > best.0 {
|
||||||
|
best = (len, i - candidate);
|
||||||
|
if len == max || len >= NICE_MATCH {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate = prev[candidate];
|
||||||
|
tries -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remember(&mut head, &mut prev, data, i);
|
||||||
|
let (length, distance) = best;
|
||||||
|
if length < MIN_MATCH {
|
||||||
|
symbol(&mut w, usize::from(data[i]));
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emit_match(&mut w, length, distance);
|
||||||
|
for at in i + 1..i + length {
|
||||||
|
remember(&mut head, &mut prev, data, at);
|
||||||
|
}
|
||||||
|
i += length;
|
||||||
|
}
|
||||||
|
symbol(&mut w, 256); // end of block
|
||||||
|
w.finish(adler32(data))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_adler32_trailer_is_rejected() {
|
||||||
|
let mut stream = deflate(b"the bytes a peer sent");
|
||||||
|
let last = stream.len() - 1;
|
||||||
|
stream[last] ^= 0xFF;
|
||||||
|
assert!(inflate(&stream).is_err(), "a bad checksum must not pass");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reference_stream_inflates() {
|
||||||
|
let stream = [
|
||||||
|
120, 156, 203, 72, 205, 201, 201, 87, 8, 207, 47, 202, 73, 1, 0,
|
||||||
|
25, 107, 4, 61,
|
||||||
|
];
|
||||||
|
assert_eq!(inflate(&stream).unwrap(), b"hello World");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listing() -> Vec<u8> {
|
||||||
|
(0..3000)
|
||||||
|
.flat_map(|n| {
|
||||||
|
format!("Artist - Album - {n:04} - Track Title.flac")
|
||||||
|
.into_bytes()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shape_round_trips() {
|
||||||
|
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||||
|
let mut noise = |n: usize| -> Vec<u8> {
|
||||||
|
(0..n)
|
||||||
|
.map(|_| {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
(seed >> 24) as u8
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let cases = [
|
||||||
|
Vec::new(),
|
||||||
|
vec![0],
|
||||||
|
vec![0x41; 258],
|
||||||
|
vec![0x41; 70_000],
|
||||||
|
vec![0x5A; 100_000],
|
||||||
|
b"the same words the same words the same words".to_vec(),
|
||||||
|
listing(),
|
||||||
|
noise(1),
|
||||||
|
noise(4096),
|
||||||
|
noise(70_000),
|
||||||
|
];
|
||||||
|
for data in cases {
|
||||||
|
assert_eq!(
|
||||||
|
inflate(&deflate(&data)).unwrap(),
|
||||||
|
data,
|
||||||
|
"{}",
|
||||||
|
data.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repetitive_listing_shrinks() {
|
||||||
|
let data = listing();
|
||||||
|
assert!(deflate(&data).len() * 4 < data.len());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_symbol_on_degenerate_tree_errors_instead_of_panicking() {
|
fn decode_symbol_on_degenerate_tree_errors_instead_of_panicking() {
|
||||||
// A malformed Huffman table from an untrusted peer can produce a node
|
// A malformed Huffman table from an untrusted peer can produce a node
|
||||||
@@ -409,28 +612,6 @@ mod tests {
|
|||||||
assert!(decode_symbol(&mut reader, &tree).is_err());
|
assert!(decode_symbol(&mut reader, &tree).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compress_stored_roundtrips_through_the_decoder() {
|
|
||||||
// Cover the block boundaries: empty, tiny, exactly 0xFFFF, and multi-block.
|
|
||||||
for size in [0usize, 1, 11, 65_534, 65_535, 65_536, 131_072, 200_000] {
|
|
||||||
let data: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
|
|
||||||
let compressed = compress_stored(&data);
|
|
||||||
assert_eq!(
|
|
||||||
deflate(&compressed).unwrap(),
|
|
||||||
data,
|
|
||||||
"roundtrip failed at size {size}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compress_stored_header_and_adler32_are_correct() {
|
|
||||||
assert_eq!(&compress_stored(b"")[0..2], &[120, 1]);
|
|
||||||
assert_eq!(adler32(b""), 1);
|
|
||||||
// a = 1+97+98+99 = 295 = 0x127; b = 98+294+589 = 981 = 0x24D.
|
|
||||||
assert_eq!(adler32(b"abc"), 0x024D_0127);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bitreader_read_bits() {
|
fn test_bitreader_read_bits() {
|
||||||
let data = vec![0b11010010, 0b10110101];
|
let data = vec![0b11010010, 0b10110101];
|
||||||
@@ -449,13 +630,6 @@ mod tests {
|
|||||||
assert_eq!(reader.read_bytes(2).unwrap(), 0x7856); // Little-endian: 0x7856
|
assert_eq!(reader.read_bytes(2).unwrap(), 0x7856); // Little-endian: 0x7856
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_header_success() {
|
|
||||||
// [120, 156] is a valid zlib header
|
|
||||||
let result = deflate(&[120, 156, 3, 0, 0, 0, 0, 1]); // Minimal valid zlib stream
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_header_fail_to_short() {
|
fn test_extract_header_fail_to_short() {
|
||||||
let data = vec![120]; // Too short
|
let data = vec![120]; // Too short
|
||||||
@@ -467,20 +641,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_deflate() {
|
fn a_dynamic_huffman_stream_from_a_real_peer_inflates() {
|
||||||
// Test data from the original test - this should work with our new implementation
|
|
||||||
let data = vec![
|
|
||||||
120, 156, 203, 72, 205, 201, 201, 87, 8, 207, 47, 202, 73, 1, 0,
|
|
||||||
24, 11, 4, 93,
|
|
||||||
];
|
|
||||||
let result = deflate(&data);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let decompressed = result.unwrap();
|
|
||||||
assert_eq!(decompressed, b"hello World");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_deflate2() {
|
|
||||||
let data = vec![
|
let data = vec![
|
||||||
120, 156, 99, 103, 96, 96, 72, 201, 79, 201, 76, 79, 204, 203, 213,
|
120, 156, 99, 103, 96, 96, 72, 201, 79, 201, 76, 79, 204, 203, 213,
|
||||||
158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135, 162, 156, 148, 194,
|
158, 98, 194, 4, 228, 50, 250, 3, 9, 7, 135, 162, 156, 148, 194,
|
||||||
@@ -522,7 +683,7 @@ mod tests {
|
|||||||
0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0,
|
||||||
]
|
]
|
||||||
.to_vec();
|
.to_vec();
|
||||||
let result = deflate(&data);
|
let result = inflate(&data);
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
let decompressed = result.unwrap();
|
let decompressed = result.unwrap();
|
||||||
|
|||||||
+491
-52
@@ -13,8 +13,7 @@
|
|||||||
//! cargo test -p soulseek-rs-lib --test e2e -- --nocapture
|
//! cargo test -p soulseek-rs-lib --test e2e -- --nocapture
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! No external crates are used — the library forbids dependencies, so the
|
//! The harness sticks to `std` and the library's own public API.
|
||||||
//! harness sticks to `std` and the library's own public API.
|
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpStream, ToSocketAddrs};
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
@@ -750,7 +749,7 @@ fn login_raw(
|
|||||||
/// passes. Returns the matching message.
|
/// passes. Returns the matching message.
|
||||||
fn read_until_code(
|
fn read_until_code(
|
||||||
stream: &mut TcpStream,
|
stream: &mut TcpStream,
|
||||||
code: u8,
|
code: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Option<Message> {
|
) -> Option<Message> {
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
@@ -767,7 +766,7 @@ fn read_until_code(
|
|||||||
/// Like [`read_until_code`] but turns a miss into an `io::Error` for `?`.
|
/// Like [`read_until_code`] but turns a miss into an `io::Error` for `?`.
|
||||||
fn expect_code(
|
fn expect_code(
|
||||||
stream: &mut TcpStream,
|
stream: &mut TcpStream,
|
||||||
code: u8,
|
code: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> std::io::Result<Message> {
|
) -> std::io::Result<Message> {
|
||||||
read_until_code(stream, code, timeout).ok_or_else(|| {
|
read_until_code(stream, code, timeout).ok_or_else(|| {
|
||||||
@@ -1379,7 +1378,7 @@ fn run_mock_direct_peer(cfg: &MockDirectUpload) -> std::io::Result<()> {
|
|||||||
p.set_nonblocking(false)?;
|
p.set_nonblocking(false)?;
|
||||||
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
p.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||||
let mut init = read_framed(&mut p)?;
|
let mut init = read_framed(&mut p)?;
|
||||||
assert_eq!(init.get_message_code(), 1, "expected inbound PeerInit");
|
assert_eq!(init.get_init_code(), 1, "expected inbound PeerInit");
|
||||||
init.set_pointer(5);
|
init.set_pointer(5);
|
||||||
assert_eq!(init.read_string(), cfg.downloader_username, "PeerInit user");
|
assert_eq!(init.read_string(), cfg.downloader_username, "PeerInit user");
|
||||||
assert_eq!(init.read_string(), "P", "PeerInit connection type");
|
assert_eq!(init.read_string(), "P", "PeerInit connection type");
|
||||||
@@ -1645,6 +1644,56 @@ fn a_file_downloads_from_a_firewalled_peer_via_server_broker() {
|
|||||||
// it — the entire search + connect + upload/download stack, no mock peer.
|
// it — the entire search + connect + upload/download stack, no mock peer.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A sharer serving `share_dir` and a searcher, both logged in and listening,
|
||||||
|
/// after a beat for soulfind to register both SetWaitPorts.
|
||||||
|
fn sharer_and_searcher(
|
||||||
|
server: &TestServer,
|
||||||
|
share_dir: &std::path::Path,
|
||||||
|
sharer: &str,
|
||||||
|
searcher: &str,
|
||||||
|
) -> (Client, Client) {
|
||||||
|
let mut sharing = Client::with_settings(ClientSettings {
|
||||||
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
|
..server.listening_settings(
|
||||||
|
sharer,
|
||||||
|
"pw",
|
||||||
|
free_port().expect("sharer port"),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
sharing.connect().expect("sharer connect");
|
||||||
|
assert!(sharing.login().expect("sharer login"));
|
||||||
|
|
||||||
|
let mut searching = Client::with_settings(server.listening_settings(
|
||||||
|
searcher,
|
||||||
|
"pw",
|
||||||
|
free_port().expect("searcher port"),
|
||||||
|
));
|
||||||
|
searching.connect().expect("searcher connect");
|
||||||
|
assert!(searching.login().expect("searcher login"));
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
(sharing, searching)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sharer`'s answer to `query`, once it reaches `searcher`.
|
||||||
|
fn reply_from(
|
||||||
|
searcher: &Client,
|
||||||
|
query: &str,
|
||||||
|
sharer: &str,
|
||||||
|
) -> Option<soulseek_rs::SearchResult> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
|
loop {
|
||||||
|
let reply = searcher
|
||||||
|
.get_search_results(query)
|
||||||
|
.into_iter()
|
||||||
|
.find(|result| result.username == sharer);
|
||||||
|
if reply.is_some() || Instant::now() >= deadline {
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_real_clients_search_and_download() {
|
fn two_real_clients_search_and_download() {
|
||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
@@ -1655,48 +1704,21 @@ fn two_real_clients_search_and_download() {
|
|||||||
let filename = "e2e_probe_xyzzy.bin";
|
let filename = "e2e_probe_xyzzy.bin";
|
||||||
std::fs::write(share_dir.join(filename), &content).unwrap();
|
std::fs::write(share_dir.join(filename), &content).unwrap();
|
||||||
|
|
||||||
let sharer_port = free_port().expect("sharer port");
|
let (sharer, leecher) =
|
||||||
let mut sharer = Client::with_settings(ClientSettings {
|
sharer_and_searcher(&server, &share_dir, "e2e_sharer", "e2e_leecher");
|
||||||
shared_directories: vec![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 query = "xyzzy";
|
||||||
let _ = leecher.search(query, Duration::from_secs(3));
|
let _ = leecher.search(query, Duration::from_secs(3));
|
||||||
|
|
||||||
// Poll until the sharer's response for our file arrives.
|
let (result_path, size) = reply_from(&leecher, query, "e2e_sharer")
|
||||||
let mut hit: Option<(String, u64)> = None;
|
.and_then(|reply| {
|
||||||
let deadline = Instant::now() + Duration::from_secs(20);
|
reply
|
||||||
while Instant::now() < deadline && hit.is_none() {
|
.files
|
||||||
for result in leecher.get_search_results(query) {
|
.into_iter()
|
||||||
if result.username == "e2e_sharer" {
|
.find(|file| file.name.contains("e2e_probe_xyzzy"))
|
||||||
for file in &result.files {
|
})
|
||||||
if file.name.contains("e2e_probe_xyzzy") {
|
.map(|file| (file.name, file.size))
|
||||||
hit = Some((file.name.clone(), file.size));
|
.expect("leecher should find the sharer's file");
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
assert_eq!(size, content.len() as u64);
|
||||||
|
|
||||||
// Download it from the sharer.
|
// Download it from the sharer.
|
||||||
@@ -1738,6 +1760,31 @@ fn two_real_clients_search_and_download() {
|
|||||||
assert_eq!(upload.username, "e2e_leecher");
|
assert_eq!(upload.username, "e2e_leecher");
|
||||||
assert_eq!(upload.bytes_sent, size);
|
assert_eq!(upload.bytes_sent, size);
|
||||||
|
|
||||||
|
// The next reply advertises what that upload measured.
|
||||||
|
let _ = leecher.search(query, Duration::from_secs(1));
|
||||||
|
let reply = reply_from(&leecher, query, "e2e_sharer")
|
||||||
|
.expect("the sharer answers again");
|
||||||
|
assert!(
|
||||||
|
reply.speed > 0,
|
||||||
|
"a finished upload sets the advertised speed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The server hears about it too (SendUploadSpeed), which is where every
|
||||||
|
// other client's `user` lookup and the server's own ranking read it.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
let mut recorded = 0;
|
||||||
|
while Instant::now() < deadline && recorded == 0 {
|
||||||
|
leecher
|
||||||
|
.request_user_info("e2e_sharer")
|
||||||
|
.expect("ask about the sharer");
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
recorded = leecher
|
||||||
|
.user_info("e2e_sharer")
|
||||||
|
.and_then(|info| info.stats)
|
||||||
|
.map_or(0, |stats| stats.average_speed);
|
||||||
|
}
|
||||||
|
assert!(recorded > 0, "the server should record the upload's speed");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(share_dir);
|
let _ = std::fs::remove_dir_all(share_dir);
|
||||||
let _ = std::fs::remove_dir_all(download_dir);
|
let _ = std::fs::remove_dir_all(download_dir);
|
||||||
}
|
}
|
||||||
@@ -2056,7 +2103,7 @@ fn a_third_party_client_browses_our_shares_via_the_server_broker() {
|
|||||||
|
|
||||||
let mut pierce = read_framed(&mut p).expect("a PierceFirewall frame");
|
let mut pierce = read_framed(&mut p).expect("a PierceFirewall frame");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pierce.get_message_code(),
|
pierce.get_init_code(),
|
||||||
0,
|
0,
|
||||||
"the brokered connect-back must start with a PierceFirewall"
|
"the brokered connect-back must start with a PierceFirewall"
|
||||||
);
|
);
|
||||||
@@ -2317,23 +2364,24 @@ fn place_of(sharer: &Client, username: &str, filename: &str) -> Option<u32> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Give `username` privileges in soulfind's own database.
|
/// Set `column` ("privileges", or "admin" for the user whose private
|
||||||
|
/// messages to "server" are commands) on `username` in soulfind's own
|
||||||
|
/// database, good for a day: soulfind stores both as the unix timestamp
|
||||||
|
/// they expire at.
|
||||||
///
|
///
|
||||||
/// `false` means it could not be done — an external server, or no `sqlite3` on
|
/// `false` means it could not be done — an external server, or no `sqlite3`
|
||||||
/// this machine — which the caller treats as a skip rather than a failure.
|
/// on this machine — which the caller treats as a skip rather than a failure.
|
||||||
fn grant_privileges(server: &TestServer, username: &str) -> bool {
|
fn grant(server: &TestServer, username: &str, column: &str) -> bool {
|
||||||
let Some(db) = server.db.as_ref() else {
|
let Some(db) = server.db.as_ref() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
// soulfind stores privileges as the unix timestamp they expire at.
|
|
||||||
let expiry = std::time::SystemTime::now()
|
let expiry = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map_or(0, |since| since.as_secs() + 86_400);
|
.map_or(0, |since| since.as_secs() + 86_400);
|
||||||
Command::new("sqlite3")
|
Command::new("sqlite3")
|
||||||
.arg(db)
|
.arg(db)
|
||||||
.arg(format!(
|
.arg(format!(
|
||||||
"UPDATE users SET privileges = {expiry} WHERE username = \
|
"UPDATE users SET {column} = {expiry} WHERE username = '{username}';"
|
||||||
'{username}';"
|
|
||||||
))
|
))
|
||||||
.status()
|
.status()
|
||||||
.is_ok_and(|status| status.success())
|
.is_ok_and(|status| status.success())
|
||||||
@@ -2442,7 +2490,7 @@ fn a_privileged_peer_overtakes_one_already_waiting_for_a_slot() {
|
|||||||
login_raw(&server_addr, "e2e_q_donor", "pw")
|
login_raw(&server_addr, "e2e_q_donor", "pw")
|
||||||
.expect("the donor registers"),
|
.expect("the donor registers"),
|
||||||
);
|
);
|
||||||
if !grant_privileges(&server, "e2e_q_donor") {
|
if !grant(&server, "e2e_q_donor", "privileges") {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"privilege e2e skipped: cannot write soulfind's database (needs a \
|
"privilege e2e skipped: cannot write soulfind's database (needs a \
|
||||||
locally spawned server and sqlite3)"
|
locally spawned server and sqlite3)"
|
||||||
@@ -3039,3 +3087,394 @@ fn watching_a_user_the_server_does_not_know_reports_their_absence() {
|
|||||||
);
|
);
|
||||||
assert!(watcher.user_info("e2e_watch_nobody_here").is_none());
|
assert!(watcher.user_info("e2e_watch_nobody_here").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A query that matches most of a big share must not ship the whole share to
|
||||||
|
// whoever typed it: a reply is a shortlist, and the searcher's other sources
|
||||||
|
// cover the rest.
|
||||||
|
#[test]
|
||||||
|
fn a_search_reply_carries_at_most_the_file_cap() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let cap = soulseek_rs::types::MAX_SEARCH_REPLY_FILES;
|
||||||
|
let share_dir = unique_download_dir();
|
||||||
|
for i in 0..cap + 20 {
|
||||||
|
std::fs::write(share_dir.join(format!("probe_cap_{i:04}.bin")), b"x")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let (_sharer, searcher) = sharer_and_searcher(
|
||||||
|
&server,
|
||||||
|
&share_dir,
|
||||||
|
"e2e_cap_sharer",
|
||||||
|
"e2e_cap_searcher",
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = "probe_cap";
|
||||||
|
let _ = searcher.search(query, Duration::from_secs(3));
|
||||||
|
|
||||||
|
let reply = reply_from(&searcher, query, "e2e_cap_sharer")
|
||||||
|
.expect("the sharer should answer the search");
|
||||||
|
assert_eq!(reply.files.len(), cap, "a reply is capped at {cap} files");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(share_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens were the first five hex digits of the query's MD5, so two queries
|
||||||
|
// could share one and a peer's answer to either went to whichever search the
|
||||||
|
// map handed back first. These two queries collide under that scheme.
|
||||||
|
#[test]
|
||||||
|
fn two_live_searches_never_share_a_token() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let share_dir = unique_download_dir();
|
||||||
|
std::fs::write(share_dir.join("tokenprobe aeabna.bin"), b"x").unwrap();
|
||||||
|
let (_sharer, searcher) = sharer_and_searcher(
|
||||||
|
&server,
|
||||||
|
&share_dir,
|
||||||
|
"e2e_token_sharer",
|
||||||
|
"e2e_token_searcher",
|
||||||
|
);
|
||||||
|
|
||||||
|
let matching = "tokenprobe aeabna";
|
||||||
|
let colliding = "tokenprobe soyvgt";
|
||||||
|
let _ = searcher.search(colliding, Duration::from_millis(100));
|
||||||
|
let _ = searcher.search(matching, Duration::from_secs(3));
|
||||||
|
|
||||||
|
let live = searcher.get_all_searches();
|
||||||
|
assert_ne!(
|
||||||
|
live[matching].token, live[colliding].token,
|
||||||
|
"two live searches must not share a token"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reply_from(&searcher, matching, "e2e_token_sharer").is_some(),
|
||||||
|
"the sharer's answer should land in the search it answers"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(share_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searchers pick sources by the free-slot flag in a reply, and every reply
|
||||||
|
// said "free" whatever the queue looked like.
|
||||||
|
#[test]
|
||||||
|
fn a_search_reply_reports_no_free_slot_while_the_only_slot_is_taken() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
let (share, folder) =
|
||||||
|
queue_share("honest", &["blocker.mp3", "probe_honest.bin"]);
|
||||||
|
let (sharer, searcher) = sharer_and_searcher(
|
||||||
|
&server,
|
||||||
|
&share,
|
||||||
|
"e2e_honest_sharer",
|
||||||
|
"e2e_honest_searcher",
|
||||||
|
);
|
||||||
|
sharer.set_upload_slots(1);
|
||||||
|
|
||||||
|
let query = "probe_honest";
|
||||||
|
let _ = searcher.search(query, Duration::from_secs(1));
|
||||||
|
let reply = reply_from(&searcher, query, "e2e_honest_sharer")
|
||||||
|
.expect("the sharer answers");
|
||||||
|
assert_eq!(reply.slots, 1, "nothing is uploading yet");
|
||||||
|
|
||||||
|
let server_addr = format!("{}:{}", server.host, server.port);
|
||||||
|
let listen_addr =
|
||||||
|
format!("127.0.0.1:{}", sharer.listen_port().expect("listening"));
|
||||||
|
let mut blocker = queue_as(
|
||||||
|
&server_addr,
|
||||||
|
&listen_addr,
|
||||||
|
"e2e_honest_blocker",
|
||||||
|
&format!("{folder}\\blocker.mp3"),
|
||||||
|
)
|
||||||
|
.expect("blocker queues");
|
||||||
|
assert!(
|
||||||
|
blocker.takes_a_slot(),
|
||||||
|
"the blocker is offered the only slot"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = searcher.search(query, Duration::from_secs(1));
|
||||||
|
let reply = reply_from(&searcher, query, "e2e_honest_sharer")
|
||||||
|
.expect("the sharer answers again");
|
||||||
|
assert_eq!(reply.slots, 0, "the only slot is taken");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(share);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Download folder" in SoulseekQt and Nicotine+ asks the sharer for that one
|
||||||
|
// folder (peer code 36) instead of the whole share; a sharer that never
|
||||||
|
// answers leaves the folder download hanging.
|
||||||
|
#[test]
|
||||||
|
fn a_third_party_client_fetches_one_folder_of_our_shares() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let share_dir = unique_download_dir();
|
||||||
|
std::fs::create_dir_all(share_dir.join("album")).unwrap();
|
||||||
|
std::fs::create_dir_all(share_dir.join("other")).unwrap();
|
||||||
|
std::fs::write(share_dir.join("album").join("one.flac"), b"xxxx").unwrap();
|
||||||
|
std::fs::write(share_dir.join("album").join("two.flac"), b"yy").unwrap();
|
||||||
|
std::fs::write(share_dir.join("other").join("three.flac"), b"z").unwrap();
|
||||||
|
let root = share_dir.file_name().unwrap().to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let sharer_port = free_port().expect("sharer port");
|
||||||
|
let mut sharer = Client::with_settings(ClientSettings {
|
||||||
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
|
..server.listening_settings("e2e_folder_sharer", "pw", sharer_port)
|
||||||
|
});
|
||||||
|
sharer.connect().expect("sharer connect");
|
||||||
|
assert!(sharer.login().expect("sharer login"));
|
||||||
|
let server_addr = format!("{}:{}", server.host, server.port);
|
||||||
|
let _qt = login_raw(&server_addr, "e2e_folder_browser", "pw")
|
||||||
|
.expect("third-party client logs in");
|
||||||
|
|
||||||
|
let folder = format!("{root}\\album");
|
||||||
|
let mut p = connect_retry(
|
||||||
|
&format!("127.0.0.1:{sharer_port}"),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)
|
||||||
|
.expect("dial the sharer");
|
||||||
|
p.set_read_timeout(Some(Duration::from_secs(15))).unwrap();
|
||||||
|
p.write_all(&peer_init_bytes("e2e_folder_browser", "P", 0))
|
||||||
|
.unwrap();
|
||||||
|
let mut request = Message::new();
|
||||||
|
request
|
||||||
|
.write_int32(36)
|
||||||
|
.write_int32(77)
|
||||||
|
.write_string(&folder);
|
||||||
|
p.write_all(&request.get_buffer()).unwrap();
|
||||||
|
p.flush().unwrap();
|
||||||
|
|
||||||
|
let mut response = expect_code(&mut p, 37, Duration::from_secs(15))
|
||||||
|
.expect("the sharer answers with a FolderContentsResponse");
|
||||||
|
response.set_pointer(8);
|
||||||
|
let (token, echoed, directories) =
|
||||||
|
soulseek_rs::message::peer::parse_folder_contents(&mut response)
|
||||||
|
.expect("a well-formed folder listing");
|
||||||
|
assert_eq!(token, 77);
|
||||||
|
assert_eq!(echoed, folder);
|
||||||
|
let mut names: Vec<(String, Vec<String>)> = directories
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| (d.name, d.files.into_iter().map(|(name, _)| name).collect()))
|
||||||
|
.collect();
|
||||||
|
for (_, files) in &mut names {
|
||||||
|
files.sort();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
[(folder, vec!["one.flac".to_string(), "two.flac".to_string()])],
|
||||||
|
"only the requested folder, with its files"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(share_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "User info" in SoulseekQt and Nicotine+ asks the peer directly (peer code
|
||||||
|
// 15); a peer that never answers shows an endless spinner.
|
||||||
|
#[test]
|
||||||
|
fn a_third_party_client_reads_our_user_info() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let share_dir = unique_download_dir();
|
||||||
|
std::fs::write(share_dir.join("probe.flac"), b"xxxx").unwrap();
|
||||||
|
let sharer_port = free_port().expect("sharer port");
|
||||||
|
let mut sharer = Client::with_settings(ClientSettings {
|
||||||
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
|
..server.listening_settings("e2e_info_sharer", "pw", sharer_port)
|
||||||
|
});
|
||||||
|
sharer.connect().expect("sharer connect");
|
||||||
|
assert!(sharer.login().expect("sharer login"));
|
||||||
|
sharer.set_upload_slots(3);
|
||||||
|
let server_addr = format!("{}:{}", server.host, server.port);
|
||||||
|
let _qt = login_raw(&server_addr, "e2e_info_asker", "pw")
|
||||||
|
.expect("third-party client logs in");
|
||||||
|
|
||||||
|
let mut p = connect_retry(
|
||||||
|
&format!("127.0.0.1:{sharer_port}"),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)
|
||||||
|
.expect("dial the sharer");
|
||||||
|
p.set_read_timeout(Some(Duration::from_secs(15))).unwrap();
|
||||||
|
p.write_all(&peer_init_bytes("e2e_info_asker", "P", 0))
|
||||||
|
.unwrap();
|
||||||
|
p.write_all(&Message::new().write_int32(15).get_buffer())
|
||||||
|
.unwrap();
|
||||||
|
p.flush().unwrap();
|
||||||
|
|
||||||
|
let mut reply = expect_code(&mut p, 16, Duration::from_secs(15))
|
||||||
|
.expect("the sharer answers with a UserInfoResponse");
|
||||||
|
reply.set_pointer(8);
|
||||||
|
let description = reply.read_string();
|
||||||
|
let has_picture = reply.read_bool();
|
||||||
|
let upload_slots = reply.read_int32();
|
||||||
|
let queue_size = reply.read_int32();
|
||||||
|
let slots_free = reply.read_bool();
|
||||||
|
assert!(description.is_empty(), "no description is configured");
|
||||||
|
assert!(!has_picture);
|
||||||
|
assert_eq!(upload_slots, 3, "the configured slot count");
|
||||||
|
assert_eq!(queue_size, 0, "nobody is waiting");
|
||||||
|
assert!(slots_free, "nothing is uploading");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(share_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress assumed every read filled the buffer, so a peer that trickles
|
||||||
|
// small chunks was reported at several times its real rate.
|
||||||
|
#[test]
|
||||||
|
fn download_progress_reports_the_rate_actually_received() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let listen_port = free_port().expect("free listen port");
|
||||||
|
let mut client = Client::with_settings(server.listening_settings(
|
||||||
|
"e2e_rate_dl",
|
||||||
|
"pw",
|
||||||
|
listen_port,
|
||||||
|
));
|
||||||
|
client.connect().expect("connect");
|
||||||
|
assert!(client.login().expect("login"));
|
||||||
|
|
||||||
|
let filename = "trickle.mp3";
|
||||||
|
let content: Vec<u8> = (0..400_000u32).map(|i| (i % 251) as u8).collect();
|
||||||
|
let download_dir = unique_download_dir();
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||||
|
let cfg = MockUpload {
|
||||||
|
listen_addr: format!("127.0.0.1:{listen_port}"),
|
||||||
|
peer_username: "e2e_rate_up".to_string(),
|
||||||
|
filename: filename.to_string(),
|
||||||
|
content: content.clone(),
|
||||||
|
token: 424_260_u32,
|
||||||
|
ready: ready_tx,
|
||||||
|
token_delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
let uploader =
|
||||||
|
std::thread::spawn(move || run_mock_slow_uploader(&cfg, None));
|
||||||
|
ready_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("mock uploader P connection");
|
||||||
|
std::thread::sleep(Duration::from_millis(1500));
|
||||||
|
|
||||||
|
let (_download, status_rx) = client
|
||||||
|
.download(
|
||||||
|
filename.to_string(),
|
||||||
|
"e2e_rate_up".to_string(),
|
||||||
|
content.len() as u64,
|
||||||
|
download_dir.display().to_string(),
|
||||||
|
)
|
||||||
|
.expect("start download");
|
||||||
|
|
||||||
|
let mut fastest = 0.0f64;
|
||||||
|
let mut completed = false;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
while Instant::now() < deadline && !completed {
|
||||||
|
match status_rx.recv_timeout(Duration::from_millis(500)) {
|
||||||
|
Ok(DownloadStatus::InProgress {
|
||||||
|
speed_bytes_per_sec,
|
||||||
|
..
|
||||||
|
}) => fastest = fastest.max(speed_bytes_per_sec),
|
||||||
|
Ok(DownloadStatus::Completed) => completed = true,
|
||||||
|
Ok(DownloadStatus::Failed(_) | DownloadStatus::TimedOut) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(completed, "the trickled download should complete");
|
||||||
|
assert!(fastest > 0.0, "progress should report a rate");
|
||||||
|
// The mock sends 4 KiB every 20 ms.
|
||||||
|
let sent_rate = 4096.0 / 0.020;
|
||||||
|
assert!(
|
||||||
|
fastest < 1.5 * sent_rate,
|
||||||
|
"reported {fastest} B/s for a peer sending {sent_rate} B/s"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = uploader.join();
|
||||||
|
let _ = std::fs::remove_dir_all(&download_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A listing went out in zlib "stored" blocks, so a big share cost every
|
||||||
|
// browser its full uncompressed size; names repeat enough to shrink well.
|
||||||
|
#[test]
|
||||||
|
fn a_large_listing_travels_compressed() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let share_dir = unique_download_dir();
|
||||||
|
let album = share_dir.join("Artist - Album");
|
||||||
|
std::fs::create_dir_all(&album).unwrap();
|
||||||
|
let mut raw_bytes = 0;
|
||||||
|
for n in 0..3000 {
|
||||||
|
let name = format!("Artist - Album - {n:04} - Track Title.flac");
|
||||||
|
raw_bytes += name.len() + 21;
|
||||||
|
std::fs::write(album.join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharer_port = free_port().expect("sharer port");
|
||||||
|
let mut sharer = Client::with_settings(ClientSettings {
|
||||||
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
|
..server.listening_settings("e2e_zlib_sharer", "pw", sharer_port)
|
||||||
|
});
|
||||||
|
sharer.connect().expect("sharer connect");
|
||||||
|
assert!(sharer.login().expect("sharer login"));
|
||||||
|
let server_addr = format!("{}:{}", server.host, server.port);
|
||||||
|
let _qt = login_raw(&server_addr, "e2e_zlib_browser", "pw")
|
||||||
|
.expect("third-party client logs in");
|
||||||
|
|
||||||
|
let mut p = connect_retry(
|
||||||
|
&format!("127.0.0.1:{sharer_port}"),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)
|
||||||
|
.expect("dial the sharer");
|
||||||
|
p.set_read_timeout(Some(Duration::from_secs(15))).unwrap();
|
||||||
|
p.write_all(&peer_init_bytes("e2e_zlib_browser", "P", 0))
|
||||||
|
.unwrap();
|
||||||
|
p.write_all(&MessageFactory::build_get_share_file_list().get_buffer())
|
||||||
|
.unwrap();
|
||||||
|
p.flush().unwrap();
|
||||||
|
let mut response = expect_code(&mut p, 5, Duration::from_secs(15))
|
||||||
|
.expect("the sharer answers the browse");
|
||||||
|
|
||||||
|
let frame = response.get_size();
|
||||||
|
assert!(
|
||||||
|
frame < raw_bytes / 4,
|
||||||
|
"a {raw_bytes}-byte listing travelled as {frame} bytes"
|
||||||
|
);
|
||||||
|
response.set_pointer(8);
|
||||||
|
let directories =
|
||||||
|
soulseek_rs::message::peer::parse_shared_file_list(&mut response);
|
||||||
|
assert_eq!(
|
||||||
|
directories.iter().map(|d| d.files.len()).sum::<usize>(),
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(share_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's announcements (code 66) were dropped on the floor; they are
|
||||||
|
// how a server tells everyone it is going down.
|
||||||
|
#[test]
|
||||||
|
fn a_server_announcement_arrives_as_a_message_from_the_server() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let mut admin = Client::with_settings(server.settings("e2e_admin", "pw"));
|
||||||
|
let mut bob =
|
||||||
|
Client::with_settings(server.settings("e2e_announce_bob", "pw"));
|
||||||
|
admin.connect().expect("admin connect");
|
||||||
|
bob.connect().expect("bob connect");
|
||||||
|
assert!(admin.login().expect("admin login"));
|
||||||
|
assert!(bob.login().expect("bob login"));
|
||||||
|
if !grant(&server, "e2e_admin", "admin") {
|
||||||
|
println!("e2e skipped: cannot make an admin on this server");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
admin
|
||||||
|
.send_private_message("server", "announcement going down at nine")
|
||||||
|
.expect("send the admin command");
|
||||||
|
|
||||||
|
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.iter().any(|m| m.username() == "server") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
let announcement = received
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.username() == "server")
|
||||||
|
.expect("bob should hear the announcement");
|
||||||
|
assert_eq!(announcement.message(), "going down at nine");
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const COLUMNS: readonly FooterColumn[] = [
|
|||||||
links: [
|
links: [
|
||||||
{ label: "GitHub", href: LINKS.gh },
|
{ label: "GitHub", href: LINKS.gh },
|
||||||
{ label: "Issues", href: LINKS.issues },
|
{ label: "Issues", href: LINKS.issues },
|
||||||
|
{ label: "Contributing", href: LINKS.contributing },
|
||||||
{ label: "Releases", href: LINKS.releases },
|
{ label: "Releases", href: LINKS.releases },
|
||||||
{ label: "Changelog", href: LINKS.changelog },
|
{ label: "Changelog", href: LINKS.changelog },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const LINKS = {
|
|||||||
issues: 'https://github.com/michel/soulseek-rs/issues',
|
issues: 'https://github.com/michel/soulseek-rs/issues',
|
||||||
releases: 'https://github.com/michel/soulseek-rs/releases',
|
releases: 'https://github.com/michel/soulseek-rs/releases',
|
||||||
changelog: 'https://github.com/michel/soulseek-rs/blob/master/CHANGELOG.md',
|
changelog: 'https://github.com/michel/soulseek-rs/blob/master/CHANGELOG.md',
|
||||||
|
contributing: 'https://github.com/michel/soulseek-rs/blob/master/CONTRIBUTING.md',
|
||||||
license: 'https://github.com/michel/soulseek-rs/blob/master/LICENSE',
|
license: 'https://github.com/michel/soulseek-rs/blob/master/LICENSE',
|
||||||
cratesClient: 'https://crates.io/crates/soulseek-rs',
|
cratesClient: 'https://crates.io/crates/soulseek-rs',
|
||||||
cratesLib: 'https://crates.io/crates/soulseek-rs-lib',
|
cratesLib: 'https://crates.io/crates/soulseek-rs-lib',
|
||||||
|
|||||||
@@ -239,10 +239,15 @@ export const Community = () => {
|
|||||||
open-source Soulseek server; run it locally to develop against a real protocol
|
open-source Soulseek server; run it locally to develop against a real protocol
|
||||||
implementation without touching the public network.
|
implementation without touching the public network.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Pull requests go to <Code>develop</Code>; <Code>master</Code> only moves on a
|
||||||
|
release.
|
||||||
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
|
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
|
||||||
<Button href={LINKS.issues} variant="accent">
|
<Button href={LINKS.issues} variant="accent">
|
||||||
Open an issue
|
Open an issue
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button href={LINKS.contributing}>Contributing</Button>
|
||||||
<Button href={LINKS.gh}>GitHub</Button>
|
<Button href={LINKS.gh}>GitHub</Button>
|
||||||
</div>
|
</div>
|
||||||
</Prose>
|
</Prose>
|
||||||
|
|||||||
Reference in New Issue
Block a user