fix(lib): harden the distributed leaf against hostile and slow parents

Review of the leaf found the seams a stranger on the other end of a D
connection could pull at, and a few that a slow one would.

- A code-93 envelope is unwrapped once, as Nicotine+ does; a frame made
  of nothing but envelopes used to recurse once per byte.
- A branch level is validated before it is used: negative levels leave
  the candidate unadoptable and the largest one no longer overflows.
- The link reads through the crate's MessageReader, so a frame split
  across a read timeout is no longer torn or mistaken for a closed link.
- Every event a link raises carries its link number, so a stale thread
  cannot adopt, close or move a newer link to the same user.
- Later branch facts from the parent move our own place and are told to
  the server; a fresh login resets the leaf, whose stance is now the one
  place the four announcement messages come from.
- Candidates from a later batch join the ones in flight instead of
  cancelling them, capped at ten; searches from the parent are budgeted
  at fifty a second; a reply queued for a searcher the server cannot
  place expires after a minute.
- Truncated frames and half-read PossibleParents entries are dropped
  instead of yielding an empty root, a level of zero or a phantom
  candidate.

The e2e now has a searcher of its own, so the answer must go to whoever
asked and not to the parent that relayed it.
This commit is contained in:
Michel de Graaf
2026-09-08 01:45:07 +02:00
parent 7d7b8fe98c
commit 9d6a5aaa98
9 changed files with 589 additions and 207 deletions
+4 -14
View File
@@ -183,12 +183,10 @@ pub struct ServerActor {
shared_folder_count: u32,
shared_file_count: u32,
session: SessionWatch,
/// The name we logged in as, once a login has been asked for.
username: String,
}
/// The messages a client sends right after a successful login: its shared-file
/// counts, its distributed-network stance, online status, and (when listening)
/// counts, online status, and (when listening)
/// the port peers should connect to. Kept as a free function so it can be
/// tested without a live connection.
fn post_login_messages(
@@ -196,7 +194,6 @@ fn post_login_messages(
listen_port: u16,
shared_folders: u32,
shared_files: u32,
own_username: &str,
) -> Vec<Message> {
let mut messages = vec![
MessageFactory::build_shared_folders_message(
@@ -205,19 +202,12 @@ fn post_login_messages(
),
MessageFactory::build_set_status_message(2),
];
messages.splice(1..1, parentless_stance(own_username));
if enable_listen {
messages.push(MessageFactory::build_set_wait_port_message(listen_port));
}
messages
}
/// What a leaf tells the server while it has no parent: it is its own branch
/// root at level 0.
fn parentless_stance(own_username: &str) -> Vec<Message> {
crate::message::distributed::stance(own_username, 0, false)
}
impl ServerActor {
#[must_use]
pub fn new(
@@ -245,7 +235,6 @@ impl ServerActor {
shared_folder_count,
shared_file_count,
session: SessionWatch::default(),
username: String::new(),
}
}
@@ -579,11 +568,13 @@ impl ServerActor {
self.listen_port,
self.shared_folder_count,
self.shared_file_count,
&self.username,
) {
self.send_message(msg);
}
self.session.clear();
// The distributed stance is the leaf's to announce, and a new
// session starts without a parent.
self.forward_to_client(ClientOperation::ResetDistributed);
}
match self.context.write_safe() {
Ok(mut ctx) => ctx.logged_in = Some(message),
@@ -651,7 +642,6 @@ impl ServerActor {
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
) {
self.username.clone_from(&username);
if self.stream.is_none() && !self.initiate_connection() {
let _ = response.send(Err(SoulseekRs::NotConnected));
return;