Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).
The bigger cuts:
- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
wrote a peer-init frame down the server socket, and the struct only
they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
UploadFailed, registry and actor getters, build_watch_user,
strip_ansi_codes, delegating shims on ClientContext. Removing public
items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
a migration, border-type helpers that all answered Rounded, the TUI's
max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
navigation blocks into cycle, hit tests into Rect::contains, char
walkers into floor/ceil_char_boundary, five Download literals into
one helper, the suites' shared scaffolding into tests/common.
Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.
Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
Running several one-shot commands at once returned "no results" for queries that
had plenty. Four defects, each of which made a dead session look like an empty
network:
- The peer listener bound its port inside a detached thread and panicked on
EADDRINUSE, unwinding that thread alone. The process carried on with no
listener, so no peer could deliver a search response. `Listen::bind` now takes
the socket before login and falls back to an ephemeral port when the
configured one is held.
- SetWaitPort advertised the configured port whether or not it was bound, so
peers dialled a sibling process that discarded responses to tickets it never
issued. The port the listener really holds is what gets advertised, mapped
through UPnP, and reported by `whoami` and `serve`.
- A zero-byte read is EOF on a TCP socket, but MessageReader reported it as
success, so a server that closed cleanly left the actor polling a dead socket.
It is now an error, and Relogged (code 41) has a handler. The peer actor
classifies that error: a control connection that was established and then
closed is a clean disconnect, because peers drop idle sockets while the bytes
move over their own connection, and reporting an error there fails every
download queued with that peer including the one streaming. A connection that
never established keeps the error path, which is what the server-brokered
fallback reads to know a peer is unreachable.
- A session the server cut off - usually because another process logged in under
the same name - exited 4, documented as "it worked and found nothing". Callers
concluded the file was unavailable. Sessions now record why they ended and
those verdicts become exit 7.
The server allows one session per account, so concurrency still needs a distinct
--username per run; SKILL.md documents that. The listener port needs no such
care any more.
Six e2e tests against soulfind cover it, each of which fails without the fix: a
taken listen port, a displaced session, three clients searching at once under
one configured port, the two CLI equivalents, and a peer that hangs up its
control connection while a transfer is still to come.
BREAKING CHANGE: `Listen::start` is replaced by `Listen::bind` and
`Listen::serve`, so the caller owns the socket and can read the port it actually
bound. `Client::listen_port` and `Client::session_loss` are new.
Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.
Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.
Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.
Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.
Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.
BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
The Login message hardcoded SoulseekQt's major version 157, so the
server could not tell soulseek-rs apart from the official client. The
protocol document now reserves a unique major version per project, so
claim 176 and send it via a new ClientVersion on ClientSettings.
Clients and bots built on the library keep major 176 and pick their own
minor version (default 1); the reference CLI/TUI logs in with minor 100.
Refs #12
An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.
New commands:
serve [--follow] stay online sharing; one record per upload
whoami credentials, connection and what we offer
user <NAME> a peer's presence and share counts
room users <ROOM> the member roster
shares list|add|remove|status|reindex
config path|list|get|set
`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.
Library additions, all request/poll pairs matching the existing conventions:
- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
logged as unhandled. The wire format is taken from soulfind's own source
and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns
Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
?-propagation via LockPoisoned, unwrap_or_default on infallible
conversions, let _ = on actor-channel sends, and a downloads_sender()
helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
into per-message helpers, main() into init_logging/run_default_tui,
download_selector and main_tui render into per-section helpers
Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
Log lines were painting over the alternate-screen TUI because some
messages bypassed the buffered logger and wrote straight to the
terminal:
- a live println! in GetPeerAddressHandler fired on every peer-address
lookup (i.e. constantly while browsing/downloading) — routed to the
buffered debug! logger.
- the TUI's error handlers used eprintln! in the render loop and in
background search/download worker threads — routed to the buffered
warn! logger so failures no longer smear the screen.
The logger already defers to a buffer while the TUI holds the screen; the
fix is simply to go through it instead of print!/eprintln!.
The same unbounded-count DoS class fixed in the room parsers also exists
in three pre-existing parsers that read a length-prefixed vector count
straight off the wire: FileSearchResponse (SearchResult), the browse
SharedFileListResponse, and ExcludedSearchPhrases. A malicious/MITM
server or peer could send a tiny frame with a ~4-billion element count
and spin the actor thread into an OOM allocation loop.
Bound each loop (and the inner attribute loops, where read_int32 does
not advance past the buffer end) by the remaining payload. Regression
tests feed a hostile count and assert prompt, empty parses.
Adversarial review of the chat-rooms feature surfaced seven confirmed
issues; all fixed with regression tests:
lib (DoS hardening — a malicious/MITM server could send a tiny frame
with a ~4-billion element count):
- room_list: bound the name/count vector loops by remaining payload.
- join_room: bound the member-list loop the same way.
TUI (soulseek-rs/src/models/rooms.rs, ui):
- a message for a room we already left no longer resurrects its tab
(route via open_index, not ensure_open).
- closing a tab now clears the unread badge on the room we land on.
- a room-list refresh keeps the highlight on the same room across a
re-sort (track selection by name, not index).
- Esc in the room list clears a lingering filter before closing, matching
the title's 'Esc: clear' hint.
- chat log renders one row per line so the newest message is never
clipped by wrapping.
Implement the Soulseek chat-room server messages against soulfind's
wire format:
- MessageFactory builders: room-list request (64), join (14),
leave (15), say (13).
- Handlers parsing the server side: RoomList (64) names+counts,
JoinRoom (14) membership, SayChatroom (13), UserJoinedRoom (16),
UserLeftRoom (17), LeaveRoom (15).
- Surface everything to the client as a drained RoomEvent stream plus
a room-list snapshot; new Client API: request_room_list, join_room,
leave_room, say_in_room, room_list, take_room_events.
Unit tests cover each builder and handler; server-gated e2e tests
exercise message delivery and room listing against soulfind.
Add Client::browse_user (sends GetShareFileList, code 4, connecting first if
needed via the same queue-and-flush path as search replies) and
take_browse_result. A new SharedFileListResponseHandler parses the peer's
code-5 reply into SharedDirectory entries, forwarded as
ClientOperation::BrowseResult and stored per user. Verified end to end: a
second real client browses a sharer and receives its file listing.
GetShareFileList (peer code 4) previously replied with a malformed message (a
server SharedFolders stats message over a peer connection). It now emits
ShareListRequested, and the client builds a real SharedFileListResponse
(code 5) from its shares grouped by directory. Adds a std-only code-5
encoder/decoder (build_shared_file_list / parse_shared_file_list) with a
roundtrip test, and Shares::directories() to group files. The decoder is the
foundation for the Browse feature.
Implement the upload path so a peer can download our shared files:
- fix the mis-coded PlaceInQueueResponse (43 -> 44) and add a real QueueUpload
handler (code 43) -> PeerMessage::IncomingQueueUpload;
- the peer actor forwards it as ClientOperation::QueueUpload; the client looks
the file up in its shares, mints an upload token (high half of the token
space, disjoint from md5 download tokens), and offers it via ServeUpload ->
build_upload_transfer_request (peer code 40, direction=upload);
- a TransferResponse for one of our serving tokens is handled by a surgical
early-return that emits StartUpload, leaving the download path untouched;
- StartUpload resolves the downloader's address (GetPeerAddress, cached) and
streams the file via a new peer::upload_peer::serve_file over an F connection.
Tested: build_upload_transfer_request decodes via the production Transfer
parser; serve_file streams a file correctly over a loopback F connection.
Wire the incoming server FileSearch (code 26): the handler now forwards
ServerMessage::FileSearchRequest -> ClientOperation::IncomingSearch instead of
just logging. The client matches the query against its shares and, if anything
matches, sends a FileSearchResponse to the searcher — connecting to them first
(GetPeerAddress -> outbound P) and flushing the response on PeerConnected when
not already connected. GetPeerAddressResponse now also caches peer addresses
for the serve path. Adds build_search_response + a unit test.
Add build_file_search_response + a borrowed FileEntry view: the zlib-compressed
reply advertising our matching shared files to a searcher. The payload is the
exact inverse of SearchResult::new_from_message (username, token, per-file
name/size/ext/attributes, slots, speed), compressed with the stored-block
encoder. Round-trip tested through the production decoder.
Implement the FIREWALLED connection mode: when a direct outbound connection to
a peer fails (peer unreachable), fall back to asking the server to broker it.
- Add MessageFactory::build_connect_to_peer (server code 18).
- A pre-established outbound failure now emits ClientOperation::PeerConnectFailed
instead of a normal disconnect; the client registers a correlation token and
sends ConnectToPeer so the peer connects back.
- The listener accepts the peer's PierceFirewall (peer code 0) connect-back,
matches the pending token, registers it as the P control connection, and
fires PeerConnected to flush the queued download.
Fix a latent aliasing bug this surfaced: a peer actor could emit two terminal
notifications (a connect-failure, then a disconnect when later replaced and
stopped), and the second removed the *newer* actor registered under the same
username, killing the brokered connection mid-transfer. An actor now reports a
terminal outcome at most once (disconnect_reported guard).
Verified end to end against soulfind: the mock advertises an unlistened port so
the direct connect is refused, the server brokers the connection, the mock
pierces back, and the file transfers — bytes match. A companion test
(soulfind_brokers_connect_to_peer_between_users) pins the server behaviour the
mode relies on.
Implement the DIRECT connection-initiation mode so a download can target a
peer the client is not already connected to:
- Fix the outbound peer handshake. on_connection_established sent
build_watch_user() (a server WatchUser message, wrongly framed with
get_data()) for outbound connections; it now sends a proper length-prefixed
PeerInit (peer code 1) with our own username. own_username is plumbed in
via PeerRegistry -> PeerActor; inbound peers (outbound=false) are unchanged.
- download() now auto-connects: if there is no control connection to the peer
it sends GetPeerAddress and defers the QueueUpload until the outbound stream
handshakes, signalled by a new ClientOperation::PeerConnected. A never-
connected client still resolves the download to Failed (unit test).
- Add Client::connect_peer(username) to open/pre-warm a control connection.
Verified end to end against soulfind: a_file_downloads_from_a_peer_via_direct_connection
logs a mock peer into the server, advertises its port via SetWaitPort, and the
client resolves the address, dials it, and downloads the file — bytes match.
The F-serve leg is shared with the passive-download mock.
Wire private messaging through the client:
- MessageFactory::build_message_user (code 22) and build_message_acked
(code 23);
- the incoming MessageUser handler now acknowledges freshly delivered
messages and forwards them to the client instead of only logging them;
- ClientContext buffers received UserMessages;
- Client::send_private_message and Client::take_private_messages expose the
feature, and UserMessage (with accessors) is re-exported from the crate.
Verified end to end against soulfind: a_private_message_is_delivered_between_users
logs in two clients and asserts one receives the other's message.
Enable the full clippy all + pedantic + nursery groups as workspace warnings,
with a small documented allow-list for the pedantic/nursery lints this project
intentionally does not follow (rustdoc-style requirements, deliberate numeric
casts in the protocol/UI layers, and a few noisy nursery lints). Also enable
the rust unused_lifetimes and unsafe_op_in_unsafe_fn lints.
Fixed every resulting warning (527 -> 0) mechanically per clippy's suggestions:
inline format args, const fns, item ordering, let-else, redundant clones,
map_or, cast_lossless, Eq derives, associated-fn conversions for methods that
never used self, and removing unnecessary Result wrappers whose call sites could
never propagate an error. No runtime behavior changed; cargo fmt applied.
cargo clippy --workspace --all-targets: 0 warnings. All tests pass.
Peer::new_from_message, NewPeer::new_from_message and the listener's
parse_peer_init_message parsed an untrusted connection-type string with
.parse().unwrap(), panicking on anything other than P/F/D. Reachable from:
- a malicious server via ConnectToPeer (kills a thread-pool worker), and
- any peer connecting to our listener (kills the accept loop).
new_from_message now returns Option and callers drop malformed messages;
the listener rejects the connection. Added a regression test for truncated
TransferRequest parsing, now safe via the bounds-checked readers.
read_string, read_int8, read_int64, read_bool and the get_message_code
accessors indexed/sliced the buffer using network-controlled offsets and
lengths with no bounds checks. An untrusted server or peer could send a
message with an oversized string length or a truncated/zero-length frame and
panic the parser; the panic unwinds out of the actor job and permanently
kills a thread-pool worker (DoS + worker leak).
All readers now bounds-check and return a safe default (empty/0/false) on
truncation, matching the existing convention already used by read_int32 and
read_raw_byte.
Port numbers are 16-bit; the codebase had listen_port/listener_port
typed as u32, inconsistent with PeerAddress.port (already u16).
Migrate the type all the way through the CLI, config, ClientSettings,
Client, ServerActor, and the listener entry point. The wire format is
preserved: build_set_wait_port_message now takes u16 and widens to u32
via .into() before write_int32. Also rename DEFALT_LISTEN_PORT to
DEFAULT_LISTEN_PORT.
- Drop dead Rooms/Room infrastructure and the rooms field on Context.
- Remove unused ActorMessage::Tick variant and the now-empty handler arm.
- Strip dead active-thread tracking machinery from ThreadPool (unused
getters, _id/_active_threads/_total_threads parameters).
- Flatten ServerActor::initiate_connection: the duplicated retry block
was a copy-paste retry against the same address with the same args
and could never succeed where the first attempt failed.
- Delete commented-out print_hex2/decode debug stubs, dead test
fixtures in login.rs and message_user.rs, the 47-line commented body
of RoomListHandler, and assorted // TODO theater.