From 41307d051023dff78a3a9de9b7d9adc9c1ad84fd Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 27 Jul 2026 11:38:46 +0200 Subject: [PATCH] fix(daemon): correct the published contract and harden the control socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review pass over the diff found seventeen confirmed defects. The worst was the contract itself: every method's parameters were published as a single descriptor named `params`, so a client generated from openrpc.json sent {"params":{"params":{…}}} and every call failed -32602. Parameters are now published per field, with a test asserting the published names are the keys the daemon actually deserializes. Security and lifecycle: - download.start no longer lets a caller name a directory, which was an arbitrary write on the daemon's host. - The Unix socket is bound under a temporary name and renamed into place; it was briefly connectable by any local user between bind and chmod, and on that transport connecting is authenticating. - Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and connections are capped, so an unauthenticated peer cannot exhaust memory or threads. - A read error no longer returns past the hub unsubscribe, which leaked a subscriber and two threads per occurrence. - A client whose event queue fills is now actually disconnected. It used to be dropped from the fan-out and left connected, working on but blind to every event after — the documentation already promised otherwise. - A client whose daemon vanishes mid-transfer is told at once instead of waiting out its full timeout and reporting a misleading "gave up". Contract accuracy: errors carry an explicit null id (a missing one reads as a notification and gets dropped), a second auth answers with the published shape, and nullable fields are sent rather than omitted so a generated client sees one shape. Also splits proto.rs and server.rs, whose concerns had diverged, and applies the review's simplifications. --- docs/daemon-protocol.md | 38 +- docs/openrpc.json | 232 ++- soulseek-rs/src/api.rs | 24 +- soulseek-rs/src/commands/daemon.rs | 4 +- soulseek-rs/src/commands/mod.rs | 2 +- soulseek-rs/src/commands/social.rs | 4 +- soulseek-rs/src/commands/transfer.rs | 2 +- soulseek-rs/src/commands/wish.rs | 2 +- soulseek-rs/src/daemon/hub.rs | 84 +- soulseek-rs/src/daemon/mod.rs | 15 +- soulseek-rs/src/daemon/openrpc.rs | 275 +++- soulseek-rs/src/daemon/proto.rs | 1294 ----------------- soulseek-rs/src/daemon/proto/dto.rs | 612 ++++++++ soulseek-rs/src/daemon/proto/envelope.rs | 150 ++ soulseek-rs/src/daemon/proto/method.rs | 176 +++ soulseek-rs/src/daemon/proto/mod.rs | 402 +++++ .../src/daemon/{server.rs => server/mod.rs} | 508 ++----- soulseek-rs/src/daemon/server/transport.rs | 429 ++++++ soulseek-rs/src/remote/mirror.rs | 75 +- soulseek-rs/src/remote/mod.rs | 56 +- soulseek-rs/src/run.rs | 65 +- soulseek-rs/tests/cli_e2e.rs | 21 +- 22 files changed, 2528 insertions(+), 1942 deletions(-) delete mode 100644 soulseek-rs/src/daemon/proto.rs create mode 100644 soulseek-rs/src/daemon/proto/dto.rs create mode 100644 soulseek-rs/src/daemon/proto/envelope.rs create mode 100644 soulseek-rs/src/daemon/proto/method.rs create mode 100644 soulseek-rs/src/daemon/proto/mod.rs rename soulseek-rs/src/daemon/{server.rs => server/mod.rs} (59%) create mode 100644 soulseek-rs/src/daemon/server/transport.rs diff --git a/docs/daemon-protocol.md b/docs/daemon-protocol.md index a92c4e6..9875741 100644 --- a/docs/daemon-protocol.md +++ b/docs/daemon-protocol.md @@ -13,8 +13,13 @@ events behave. ## Framing Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions, -UTF-8, no length prefix. A line that does not parse gets a `-32700` reply and -the connection stays open. +UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on +an established connection it stays open, but the *first* line has to be a valid +`auth` request, so a parse error there ends the connection. + +Lines are capped at 1 MiB and a connection that has not authenticated within +30 seconds is closed. The daemon serves at most 64 control connections at +once. ## Transports and authentication @@ -57,9 +62,14 @@ the old shape. ## Requests -Parameters are by name — a JSON object, never an array. Every method and its -parameter and result schemas are in `openrpc.json`; `rpc.discover` returns -that same document from the running daemon. +Parameters are by name — a JSON object, never an array. Each method's fields +are published as individual OpenRPC content descriptors, so a generated client +sends exactly the object the daemon deserializes. `rpc.discover` returns that +same document from the running daemon. + +An event is the exception: its `params` object *is* the payload, published as +a single `payload` descriptor because several payloads are tagged unions with +no fixed field set. Requests may be pipelined and are answered out of order, so match replies by `id`. A request without an `id` is a notification and is answered with @@ -71,6 +81,10 @@ silence, including when it fails. {"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}} ``` +An error always carries an `id`, explicitly `null` when the request was too +malformed to have one — so do not read a missing `id` as "this is a +notification" and drop it. + Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown method, `-32602` bad parameters) mean what they always mean. Application failures use `-32000` and carry `data.exit`: the CLI exit status the same @@ -100,6 +114,12 @@ where they appear as entries marked `x-notification` — OpenRPC has no first-class notion of a server-pushed message, so that flag is how they are distinguished from methods you may call. +`message.history` and `event.message` describe the same conversation in +different shapes, deliberately: a live event is the message as the server +delivered it (`id`, `timestamp`, `username`, `message`), while history is a +chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it +includes what this account sent. + **Events are the only way to observe these things, and they are not replayable.** The underlying library hands over its event buffer and empties it, so the daemon drains once and copies to every attached connection. A @@ -118,10 +138,10 @@ matched on `username` + `filename`. ## Behaviour worth knowing -**Files land on the daemon's filesystem.** `download_directory` is the -daemon's configuration; a remote client cannot see that filesystem and does -not choose. `daemon.status` reports the directory in use. There is no method -to fetch the bytes over the control socket. +**Files land on the daemon's filesystem.** The directory is the daemon's +configuration and a client cannot name one — that would be an arbitrary write +on the daemon's host. `daemon.status` reports the directory in use. There is +no method to fetch the bytes over the control socket. **Several clients, no locking.** Last write wins. Two clients cancelling the same transfer is two hands on one keyboard, not an error. diff --git a/docs/openrpc.json b/docs/openrpc.json index 9c34c0a..12d5037 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -115,6 +115,7 @@ "type": "string" }, "listen_port": { + "default": null, "format": "uint16", "maximum": 65535, "minimum": 0, @@ -139,7 +140,8 @@ { "type": "null" } - ] + ], + "default": null }, "shared_files": { "format": "uint32", @@ -197,9 +199,15 @@ }, "metadata": { "$ref": "#/components/schemas/DownloadMetadataDto", - "default": {} + "default": { + "bitrate": null, + "length_seconds": null, + "peer_free_slots": null, + "peer_upload_speed": null + } }, "queue_position": { + "default": null, "format": "uint32", "minimum": 0, "type": [ @@ -237,6 +245,7 @@ "DownloadMetadataDto": { "properties": { "bitrate": { + "default": null, "format": "uint32", "minimum": 0, "type": [ @@ -245,6 +254,7 @@ ] }, "length_seconds": { + "default": null, "format": "uint32", "minimum": 0, "type": [ @@ -253,6 +263,7 @@ ] }, "peer_free_slots": { + "default": null, "format": "uint8", "maximum": 255, "minimum": 0, @@ -262,6 +273,7 @@ ] }, "peer_upload_speed": { + "default": null, "format": "uint32", "minimum": 0, "type": [ @@ -274,19 +286,17 @@ }, "DownloadStartParams": { "properties": { - "download_directory": { - "description": "Where the file lands *on the daemon's* filesystem. Defaults to the\ndaemon's configured download directory, which is the only sensible\nchoice for a remote caller that cannot see that filesystem.", - "type": [ - "string", - "null" - ] - }, "filename": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/DownloadMetadataDto", - "default": {} + "default": { + "bitrate": null, + "length_seconds": null, + "peer_free_slots": null, + "peer_upload_speed": null + } }, "size": { "default": 0, @@ -780,6 +790,7 @@ "Seconds": { "properties": { "seconds": { + "default": null, "format": "uint32", "minimum": 0, "type": [ @@ -1031,7 +1042,8 @@ { "type": "null" } - ] + ], + "default": null }, "stats": { "anyOf": [ @@ -1041,7 +1053,8 @@ { "type": "null" } - ] + ], + "default": null }, "username": { "type": "string" @@ -1120,7 +1133,8 @@ { "type": "null" } - ] + ], + "default": null } }, "type": "object" @@ -1166,10 +1180,24 @@ "paramStructure": "by-name", "params": [ { - "name": "params", - "required": true, + "name": "protocol", + "required": false, "schema": { - "$ref": "#/components/schemas/AuthParams" + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + { + "name": "token", + "required": false, + "schema": { + "description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.", + "type": [ + "string", + "null" + ] } } ], @@ -1222,10 +1250,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "query", "required": true, "schema": { - "$ref": "#/components/schemas/QueryParams" + "type": "string" } } ], @@ -1242,10 +1270,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "query", "required": true, "schema": { - "$ref": "#/components/schemas/QueryParams" + "type": "string" } } ], @@ -1262,10 +1290,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "query", "required": true, "schema": { - "$ref": "#/components/schemas/QueryParams" + "type": "string" } } ], @@ -1294,10 +1322,40 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "filename", "required": true, "schema": { - "$ref": "#/components/schemas/DownloadStartParams" + "type": "string" + } + }, + { + "name": "metadata", + "required": false, + "schema": { + "$ref": "#/components/schemas/DownloadMetadataDto", + "default": { + "bitrate": null, + "length_seconds": null, + "peer_free_slots": null, + "peer_upload_speed": null + } + } + }, + { + "name": "size", + "required": false, + "schema": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1326,10 +1384,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "filename", "required": true, "schema": { - "$ref": "#/components/schemas/TransferRef" + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1346,10 +1411,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "filename", "required": true, "schema": { - "$ref": "#/components/schemas/TransferRef" + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1366,10 +1438,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "filename", "required": true, "schema": { - "$ref": "#/components/schemas/TransferRef" + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1386,10 +1465,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "filename", "required": true, "schema": { - "$ref": "#/components/schemas/TransferRef" + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1418,10 +1504,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "filename", "required": true, "schema": { - "$ref": "#/components/schemas/TransferRef" + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1438,10 +1531,12 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "slots", "required": true, "schema": { - "$ref": "#/components/schemas/SlotsParams" + "format": "uint", + "minimum": 0, + "type": "integer" } } ], @@ -1494,10 +1589,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "room", "required": true, "schema": { - "$ref": "#/components/schemas/RoomRef" + "type": "string" } } ], @@ -1514,10 +1609,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "room", "required": true, "schema": { - "$ref": "#/components/schemas/RoomRef" + "type": "string" } } ], @@ -1534,10 +1629,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "message", "required": true, "schema": { - "$ref": "#/components/schemas/SayParams" + "type": "string" + } + }, + { + "name": "room", + "required": true, + "schema": { + "type": "string" } } ], @@ -1554,10 +1656,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "room", "required": true, "schema": { - "$ref": "#/components/schemas/RoomRef" + "type": "string" } } ], @@ -1574,10 +1676,17 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "message", "required": true, "schema": { - "$ref": "#/components/schemas/MessageParams" + "type": "string" + } + }, + { + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -1606,10 +1715,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "username", "required": true, "schema": { - "$ref": "#/components/schemas/UserRef" + "type": "string" } } ], @@ -1626,10 +1735,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "username", "required": true, "schema": { - "$ref": "#/components/schemas/UserRef" + "type": "string" } } ], @@ -1646,10 +1755,10 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "username", "required": true, "schema": { - "$ref": "#/components/schemas/UserRef" + "type": "string" } } ], @@ -1678,10 +1787,13 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "directories", "required": true, "schema": { - "$ref": "#/components/schemas/DirectoriesParams" + "items": { + "type": "string" + }, + "type": "array" } } ], @@ -1710,7 +1822,7 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "payload", "required": true, "schema": { "$ref": "#/components/schemas/RoomEventDto" @@ -1731,7 +1843,7 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "payload", "required": true, "schema": { "$ref": "#/components/schemas/UserMessageDto" @@ -1752,7 +1864,7 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "payload", "required": true, "schema": { "$ref": "#/components/schemas/UploadInfoDto" @@ -1773,7 +1885,7 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "payload", "required": true, "schema": { "$ref": "#/components/schemas/DownloadStatusEvent" @@ -1794,7 +1906,7 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "payload", "required": true, "schema": { "$ref": "#/components/schemas/BrowseEvent" @@ -1815,7 +1927,7 @@ "paramStructure": "by-name", "params": [ { - "name": "params", + "name": "payload", "required": true, "schema": { "$ref": "#/components/schemas/SessionLossEvent" diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 4493d94..d54330f 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -39,11 +39,16 @@ pub trait SessionApi: Send + Sync { fn listen_port(&self) -> Option; fn session_loss(&self) -> Option; + /// Search and wait out the window. Every implementation is + /// `search_with_cancel` with nothing to cancel on. fn search( &self, query: &str, timeout: Duration, - ) -> Result>; + ) -> Result> { + self.search_with_cancel(query, timeout, None) + } + fn search_with_cancel( &self, query: &str, @@ -91,7 +96,8 @@ pub trait SessionApi: Send + Sync { fn room_members(&self, room: &str) -> Vec; fn take_room_events(&self) -> Vec; - fn send_private_message(&self, username: &str, message: &str) -> Result<()>; + fn send_private_message(&self, username: &str, message: &str) + -> Result<()>; fn take_private_messages(&self) -> Vec; fn browse_user(&self, username: &str) -> Result<()>; @@ -133,14 +139,6 @@ impl SessionApi for Client { Self::session_loss(self) } - fn search( - &self, - query: &str, - timeout: Duration, - ) -> Result> { - Self::search(self, query, timeout) - } - fn search_with_cancel( &self, query: &str, @@ -266,7 +264,11 @@ impl SessionApi for Client { Self::take_room_events(self) } - fn send_private_message(&self, username: &str, message: &str) -> Result<()> { + fn send_private_message( + &self, + username: &str, + message: &str, + ) -> Result<()> { Self::send_private_message(self, username, message) } diff --git a/soulseek-rs/src/commands/daemon.rs b/soulseek-rs/src/commands/daemon.rs index 984ae51..8f41fd4 100644 --- a/soulseek-rs/src/commands/daemon.rs +++ b/soulseek-rs/src/commands/daemon.rs @@ -6,7 +6,9 @@ //! credentials are demanded. use crate::daemon::proto::{DaemonStatus, Method}; -use crate::output::{CliError, CliResult, DaemonStatusRecord, Out, TokenRecord}; +use crate::output::{ + CliError, CliResult, DaemonStatusRecord, Out, TokenRecord, +}; use crate::remote::{Endpoint, RemoteSession}; /// Print the token a client on another machine needs. diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index b7071b1..6394ab0 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -14,13 +14,13 @@ pub mod social; pub mod transfer; pub mod wish; +use crate::api::SessionApi; use crate::cli::{Commands, MessageCommand, RoomCommand}; use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord}; use crate::port_mapping::{self, PortMapper}; use crate::remote::{Endpoint, RemoteSession}; use soulseek_rs::types::{RoomEvent, RoomInfo}; use soulseek_rs::{Client, ClientSettings, SessionLoss}; -use crate::api::SessionApi; use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index 613581a..168c92d 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -2,14 +2,14 @@ //! `message`. use super::{Ctx, Session, confirm_flush}; +use crate::api::SessionApi; use crate::cli::BrowseArgs; use crate::output::{ BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord, RoomEventRecord, RoomMemberRecord, RoomRecord, }; -use soulseek_rs::types::RoomEvent; -use crate::api::SessionApi; use soulseek_rs::SharedDirectory; +use soulseek_rs::types::RoomEvent; use std::time::{Duration, Instant}; /// How long to wait for the server to acknowledge something we sent. diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs index d902f40..62e0a40 100644 --- a/soulseek-rs/src/commands/transfer.rs +++ b/soulseek-rs/src/commands/transfer.rs @@ -1,6 +1,7 @@ //! Finding files and fetching them: `search`, `download`, and `get`. use super::{Ctx, Session, social}; +use crate::api::SessionApi; use crate::cli::{ DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey, }; @@ -8,7 +9,6 @@ use crate::output::{ CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord, }; use soulseek_rs::utils::path::expand_tilde; -use crate::api::SessionApi; use soulseek_rs::{DownloadStatus, SearchResult}; use std::collections::{HashMap, VecDeque}; use std::io::BufRead; diff --git a/soulseek-rs/src/commands/wish.rs b/soulseek-rs/src/commands/wish.rs index 82b65b6..7da9488 100644 --- a/soulseek-rs/src/commands/wish.rs +++ b/soulseek-rs/src/commands/wish.rs @@ -6,10 +6,10 @@ //! set — we only decide which queries to send and what to print. use super::{Ctx, Session, settings::Store, transfer}; +use crate::api::SessionApi; use crate::cli::{SortKey, WishRunArgs}; use crate::output::{CliError, CliResult, Out, WishRecord}; use crate::persist::config::FileConfig; -use crate::api::SessionApi; use soulseek_rs::Client; use std::time::Instant; diff --git a/soulseek-rs/src/daemon/hub.rs b/soulseek-rs/src/daemon/hub.rs index 492c444..2366f7f 100644 --- a/soulseek-rs/src/daemon/hub.rs +++ b/soulseek-rs/src/daemon/hub.rs @@ -12,7 +12,7 @@ use super::proto::{ BrowseEvent, ChatMessageDto, DownloadStatusEvent, Event, Response, - SessionLossEvent, UploadInfoDto, UserMessageDto, + SessionLossEvent, UploadInfoDto, UploadStatusDto, UserMessageDto, }; use crate::api::SessionApi; use soulseek_rs::DownloadStatus; @@ -39,6 +39,14 @@ const HISTORY_LIMIT: usize = 500; struct Subscriber { id: u64, sender: SyncSender, + /// How to end this client's connection. A stalled client cannot be + /// reached any other way, and leaving it attached but silently eventless + /// is worse than disconnecting it: it would keep working while missing + /// every room message, transfer update and upload it should have seen. + /// + /// A closure rather than the socket itself, so the hub stays ignorant of + /// what a connection is made of. + disconnect: Box, } /// Fans daemon-side events out to every attached connection. @@ -61,12 +69,20 @@ impl Hub { } /// Attach a connection. The returned receiver yields notifications until - /// [`Hub::unsubscribe`] or a full queue drops it. - pub fn subscribe(&self) -> (u64, Receiver) { + /// [`Hub::unsubscribe`], or until a full queue costs the client its + /// connection. + pub fn subscribe( + &self, + disconnect: impl Fn() + Send + Sync + 'static, + ) -> (u64, Receiver) { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (sender, receiver) = sync_channel(QUEUE_DEPTH); if let Ok(mut subscribers) = self.subscribers.lock() { - subscribers.push(Subscriber { id, sender }); + subscribers.push(Subscriber { + id, + sender, + disconnect: Box::new(disconnect), + }); } (id, receiver) } @@ -78,7 +94,9 @@ impl Hub { } pub fn subscribers(&self) -> usize { - self.subscribers.lock().map_or(0, |subscribers| subscribers.len()) + self.subscribers + .lock() + .map_or(0, |subscribers| subscribers.len()) } /// Send one event to everyone, dropping any client that has stopped @@ -93,9 +111,14 @@ impl Hub { Ok(()) => true, Err(TrySendError::Full(_)) => { soulseek_rs::warn!( - "control client {} is not reading its events; dropping it", + "control client {} is not reading its events; \ + disconnecting it", subscriber.id ); + // Closing the socket is the point: a client dropped from + // the fan-out but left connected would carry on believing + // it was subscribed, silently blind to everything after. + (subscriber.disconnect)(); false } Err(TrySendError::Disconnected(_)) => false, @@ -192,11 +215,8 @@ impl PendingBrowses { pending.iter().map(|(name, _)| name.clone()).collect() } - fn settled(&self, username: &str) { - self.forget(username); - } - - /// Stop waiting on a peer whose request never reached the wire. + /// Stop waiting on a peer — because it answered, or because its request + /// never reached the wire. pub fn forget(&self, username: &str) { if let Ok(mut pending) = self.0.lock() { pending.retain(|(name, _)| name != username); @@ -272,7 +292,11 @@ fn tick( /// Both sources matter: the drained events carry states that came and went /// between two ticks (a small file served straight out of the queue), and the /// live table carries progress. -fn publish_uploads(session: &dyn SessionApi, hub: &Hub, state: &mut DrainState) { +fn publish_uploads( + session: &dyn SessionApi, + hub: &Hub, + state: &mut DrainState, +) { let sampled = session .take_upload_events() .iter() @@ -285,11 +309,24 @@ fn publish_uploads(session: &dyn SessionApi, hub: &Hub, state: &mut DrainState) if state.uploads.get(&key) == Some(&upload) { continue; } + let settled = matches!( + upload.status, + UploadStatusDto::Completed + | UploadStatusDto::Cancelled + | UploadStatusDto::Failed { .. } + ); hub.publish( Event::Upload, serde_json::to_value(&upload).unwrap_or(serde_json::Value::Null), ); - state.uploads.insert(key, upload); + // An upload that has finished cannot change again, so remembering it + // would grow this table by one entry per file for the life of the + // daemon. + if settled { + state.uploads.remove(&key); + } else { + state.uploads.insert(key, upload); + } } } @@ -302,7 +339,7 @@ fn publish_browses( let Some(directories) = session.take_browse_result(&username) else { continue; }; - browses.settled(&username); + browses.forget(&username); hub.publish( Event::Browse, serde_json::to_value(BrowseEvent { @@ -358,8 +395,8 @@ mod tests { // The whole reason the hub exists: two clients must both see an event // that only exists once in the library's buffer. let hub = Hub::new(); - let (_, first) = hub.subscribe(); - let (_, second) = hub.subscribe(); + let (_, first) = hub.subscribe(|| {}); + let (_, second) = hub.subscribe(|| {}); hub.publish(Event::Room, json!({ "event": "left", "room": "lobby" })); @@ -370,7 +407,7 @@ mod tests { #[test] fn unsubscribing_stops_delivery() { let hub = Hub::new(); - let (id, receiver) = hub.subscribe(); + let (id, receiver) = hub.subscribe(|| {}); hub.unsubscribe(id); hub.publish(Event::Upload, json!({})); assert!(drain(&receiver).is_empty()); @@ -380,8 +417,8 @@ mod tests { #[test] fn a_client_that_stops_reading_is_dropped_and_the_others_keep_up() { let hub = Hub::new(); - let (_, slow) = hub.subscribe(); - let (_, healthy) = hub.subscribe(); + let (_, slow) = hub.subscribe(|| {}); + let (_, healthy) = hub.subscribe(|| {}); // Overrun the slow client's queue while the healthy one keeps reading, // which is exactly the asymmetry the bound exists to survive. @@ -408,7 +445,7 @@ mod tests { #[test] fn a_dropped_receiver_removes_its_subscriber() { let hub = Hub::new(); - let (_, receiver) = hub.subscribe(); + let (_, receiver) = hub.subscribe(|| {}); drop(receiver); hub.publish(Event::Message, json!({})); assert_eq!(hub.subscribers(), 0); @@ -427,7 +464,10 @@ mod tests { } let history = hub.history(); assert_eq!(history.len(), HISTORY_LIMIT); - assert_eq!(history[0].at, 5, "the oldest messages are the ones dropped"); + assert_eq!( + history[0].at, 5, + "the oldest messages are the ones dropped" + ); assert_eq!(history[HISTORY_LIMIT - 1].at, HISTORY_LIMIT as i64 + 4); } @@ -460,7 +500,7 @@ mod tests { pending.expect("bob"); pending.expect("bob"); assert_eq!(pending.snapshot(), ["bob"]); - pending.settled("bob"); + pending.forget("bob"); assert!(pending.snapshot().is_empty()); } diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 02e4527..a8eaab1 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -190,7 +190,7 @@ pub fn run( let downloads = Arc::new(Mutex::new(Vec::new())); let stop = Arc::new(AtomicBool::new(false)); - restore(ctx, &store, session.client.as_ref(), &hub, &downloads); + let rooms = restore(ctx, &store, session.client.as_ref(), &hub, &downloads); let daemon = Arc::new(Daemon { session: Arc::clone(&session.client), @@ -202,7 +202,8 @@ pub fn run( token, started: Instant::now(), stop: Arc::clone(&stop), - rooms: Mutex::new(store.load_rooms()), + open: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + rooms: Mutex::new(rooms), }); let drainer = { @@ -263,7 +264,7 @@ fn restore( session: &dyn SessionApi, hub: &Hub, downloads: &Mutex>, -) { +) -> Vec { hub.restore( store .load_messages() @@ -277,8 +278,9 @@ fn restore( .collect(), ); - for room in store.load_rooms() { - if let Err(e) = session.join_room(&room) { + let rooms = store.load_rooms(); + for room in &rooms { + if let Err(e) = session.join_room(room) { ctx.out.warn(&format!("cannot rejoin {room}: {e}")); } } @@ -289,7 +291,7 @@ fn restore( .filter(|download| !download.completed) .collect(); if unfinished.is_empty() { - return; + return rooms; } ctx.out .status(&format!("resuming {} transfers", unfinished.len())); @@ -314,6 +316,7 @@ fn restore( .warn(&format!("cannot resume {}: {e}", download.filename)), } } + rooms } /// Write daemon-owned state back. Best-effort: a state file that cannot be diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index 8ba9f82..b60c5db 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -9,12 +9,12 @@ //! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`. use super::proto::{ - Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DownloadStartParams, - DownloadStatusEvent, Downloads, DownloadStarted, Event, IntervalSeconds, - MessageParams, Members, Messages, Method, QueryParams, RoomEventDto, - RoomRef, SayParams, SearchResults, Seconds, SessionLossEvent, SharesStatus, - SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, - UserResult, DirectoriesParams, + Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, + DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads, + Event, IntervalSeconds, Members, MessageParams, Messages, Method, + QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Seconds, + SessionLossEvent, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, + Uploads, UserMessageDto, UserRef, UserResult, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -117,15 +117,29 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { /// What a method does, for the generated documentation. fn summary(method: Method) -> &'static str { match method { - Method::Auth => "Authenticate. Must be the first request on a connection.", - Method::DaemonStatus => "Who the daemon is logged in as and what its session is doing.", + Method::Auth => { + "Authenticate. Must be the first request on a connection." + } + Method::DaemonStatus => { + "Who the daemon is logged in as and what its session is doing." + } Method::DaemonStop => "Ask the daemon to shut down.", Method::RpcDiscover => "This document.", - Method::SearchStart => "Put a search on the wire and return at once; results accumulate.", - Method::SearchResults => "Everything that has answered a search so far.", - Method::SearchWishlist => "Send a query as a wishlist search (server code 103).", - Method::SearchWishlistInterval => "How long the server wants between wishlist searches.", - Method::DownloadStart => "Queue a transfer. Progress arrives as event.download_status.", + Method::SearchStart => { + "Put a search on the wire and return at once; results accumulate." + } + Method::SearchResults => { + "Everything that has answered a search so far." + } + Method::SearchWishlist => { + "Send a query as a wishlist search (server code 103)." + } + Method::SearchWishlistInterval => { + "How long the server wants between wishlist searches." + } + Method::DownloadStart => { + "Queue a transfer. Progress arrives as event.download_status." + } Method::DownloadList => "Every transfer the daemon knows about.", Method::DownloadPause => "Pause a transfer in progress.", Method::DownloadResume => "Resume a paused transfer.", @@ -134,17 +148,29 @@ fn summary(method: Method) -> &'static str { Method::UploadList => "Uploads being served, active first.", Method::UploadCancel => "Stop serving an upload in progress.", Method::UploadSlots => "How many uploads may run at once.", - Method::PrivilegesCheck => "Ask the server for this account's privilege time.", - Method::PrivilegesOwn => "Privilege seconds left, or null if the server has not answered.", - Method::RoomListRequest => "Ask the server for the public room list; it arrives as event.room.", + Method::PrivilegesCheck => { + "Ask the server for this account's privilege time." + } + Method::PrivilegesOwn => { + "Privilege seconds left, or null if the server has not answered." + } + Method::RoomListRequest => { + "Ask the server for the public room list; it arrives as event.room." + } Method::RoomJoin => "Join a chat room.", Method::RoomLeave => "Leave a chat room.", Method::RoomSay => "Post a message to a room.", Method::RoomMembers => "Who is currently in a room.", Method::MessageSend => "Send a private message.", - Method::MessageHistory => "Private messages the daemon collected, including while nobody was attached.", - Method::BrowseUser => "Ask a peer for its share listing; it arrives as event.browse.", - Method::UserRequest => "Ask the server about a user; poll user.info for the answer.", + Method::MessageHistory => { + "Private messages the daemon collected, including while nobody was attached." + } + Method::BrowseUser => { + "Ask a peer for its share listing; it arrives as event.browse." + } + Method::UserRequest => { + "Ask the server about a user; poll user.info for the answer." + } Method::UserInfoOf => "What the server has said about a user so far.", Method::SharesStatusOf => "What the share index currently holds.", Method::SharesSet => "Replace the shared folders and re-index.", @@ -171,10 +197,61 @@ fn event_summary(event: Event) -> &'static str { Event::Upload => "An upload changed state.", Event::DownloadStatus => "A transfer moved.", Event::Browse => "A peer answered a browse request.", - Event::SessionLoss => "The server session ended; nothing seen after this means anything.", + Event::SessionLoss => { + "The server session ended; nothing seen after this means anything." + } } } +/// Turn a params object schema into OpenRPC content descriptors — one per +/// field, which is what `by-name` means. +/// +/// A single descriptor holding the whole struct would tell a generator to send +/// `{"params": {"params": {…}}}`, and every generated call would come back +/// `-32602`. The daemon deserializes the params object directly into the +/// struct, so each of its fields is a parameter in its own right. +fn descriptors( + params: &Value, + definitions: &serde_json::Map, +) -> Vec { + let Some(schema) = resolve(params, definitions) else { + return Vec::new(); + }; + let required: Vec<&str> = schema + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + schema + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .map(|(name, property)| { + json!({ + "name": name, + "required": required.contains(&name.as_str()), + "schema": property, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Follow a `$ref` into the generated definitions. +fn resolve<'a>( + schema: &'a Value, + definitions: &'a serde_json::Map, +) -> Option<&'a Value> { + let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { + return schema.get("properties").map(|_| schema); + }; + definitions.get(reference.strip_prefix(DEFINITIONS_PATH)?) +} + /// Build the whole document. fn document() -> Value { let settings = SchemaSettings::draft2020_12().with(|settings| { @@ -182,56 +259,62 @@ fn document() -> Value { }); let mut generator = SchemaGenerator::new(settings); - let methods: Vec = Method::ALL + // Schemas first, definitions second: a params schema is a `$ref` until the + // generator has been asked for everything, and expanding it into + // descriptors needs the definition it points at. + let shapes: Vec<(Method, Value, Value)> = Method::ALL .iter() .map(|&method| { - let params = params_schema(method, &mut generator); - let result = result_schema(method, &mut generator); - json!({ - "name": method.wire_name(), - "summary": summary(method), - "paramStructure": "by-name", - "params": if params == json!({ "type": "null" }) { - json!([]) - } else { - json!([{ - "name": "params", - "required": true, - "schema": params, - }]) - }, - "result": { - "name": "result", - "schema": result, - }, - }) + ( + method, + params_schema(method, &mut generator), + result_schema(method, &mut generator), + ) }) .collect(); + let event_shapes: Vec<(Event, Value)> = Event::ALL + .iter() + .map(|&event| (event, event_schema(event, &mut generator))) + .collect(); + + let definitions: serde_json::Map = + generator.take_definitions(true).into_iter().collect(); + + let methods = shapes.into_iter().map(|(method, params, result)| { + json!({ + "name": method.wire_name(), + "summary": summary(method), + "paramStructure": "by-name", + "params": descriptors(¶ms, &definitions), + "result": { + "name": "result", + "schema": result, + }, + }) + }); // OpenRPC has no first-class notion of a server-pushed notification, so // the events are published as methods flagged with an extension field. // A generator still emits their payload types; the delivery semantics are // prose in docs/daemon-protocol.md. - let events: Vec = Event::ALL - .iter() - .map(|&event| { - json!({ - "name": event.wire_name(), - "summary": event_summary(event), - "x-notification": true, - "paramStructure": "by-name", - "params": [{ - "name": "params", - "required": true, - "schema": event_schema(event, &mut generator), - }], - "result": { "name": "result", "schema": { "type": "null" } }, - }) + let events = event_shapes.into_iter().map(|(event, payload)| { + json!({ + "name": event.wire_name(), + "summary": event_summary(event), + "x-notification": true, + "paramStructure": "by-name", + // Unlike a method, an event's `params` object *is* the payload, + // and several payloads are tagged enums with no single property + // map to split. One descriptor carrying the whole schema is + // what a generator needs to emit the type. + "params": [{ + "name": "payload", + "required": true, + "schema": payload, + }], + "result": { "name": "result", "schema": { "type": "null" } }, }) - .collect(); - - let definitions: serde_json::Map = - generator.take_definitions(true).into_iter().collect(); + }); json!({ "openrpc": "1.3.2", @@ -239,11 +322,11 @@ fn document() -> Value { "title": "soulseek-rs daemon", "version": env!("CARGO_PKG_VERSION"), "description": "Control a running soulseek-rs daemon. Newline-delimited \ -JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \ -TCP (remote, authenticated by the token in /daemon.token). `auth` must \ -be the first request on a connection. Errors carry the CLI exit status in \ -`error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \ -are never requested.", + JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \ + TCP (remote, authenticated by the token in /daemon.token). `auth` must \ + be the first request on a connection. Errors carry the CLI exit status in \ + `error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \ + are never requested.", "license": { "name": "MIT" }, }, "methods": methods.into_iter().chain(events).collect::>(), @@ -306,6 +389,68 @@ fn every_method_and_event_is_published() { assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len()); } +/// The published parameters must be the keys the daemon actually reads. +/// +/// This is the shape a generated client sends. Publishing the params struct as +/// one descriptor named `params` type-checks and generates cleanly, and every +/// call it produces fails `-32602`, because the client then sends +/// `{"params": {"params": {…}}}` where the daemon expects the inner object. +#[test] +fn published_parameters_are_the_keys_the_daemon_reads() { + use super::proto::{QueryParams, RoomRef, TransferRef}; + + let document = document(); + let published = |name: &str| -> Vec { + document["methods"] + .as_array() + .expect("methods is an array") + .iter() + .find(|method| method["name"] == name) + .unwrap_or_else(|| panic!("{name} is published"))["params"] + .as_array() + .expect("params is an array") + .iter() + .map(|p| p["name"].as_str().expect("a name").to_string()) + .collect() + }; + let keys = |value: &Value| -> Vec { + value + .as_object() + .expect("params serialize as an object") + .keys() + .cloned() + .collect() + }; + + assert_eq!( + published("room.join"), + keys( + &serde_json::to_value(RoomRef { + room: "lobby".into() + }) + .unwrap() + ) + ); + assert_eq!( + published("search.results"), + keys(&serde_json::to_value(QueryParams { query: "x".into() }).unwrap()) + ); + assert_eq!( + published("download.pause"), + keys( + &serde_json::to_value(TransferRef { + username: "bob".into(), + filename: "a.mp3".into() + }) + .unwrap() + ) + ); + assert!( + published("daemon.status").is_empty(), + "a method that takes nothing publishes no parameters" + ); +} + #[test] fn a_wire_name_round_trips_through_its_enum() { for method in Method::ALL { diff --git a/soulseek-rs/src/daemon/proto.rs b/soulseek-rs/src/daemon/proto.rs deleted file mode 100644 index 299097a..0000000 --- a/soulseek-rs/src/daemon/proto.rs +++ /dev/null @@ -1,1294 +0,0 @@ -//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it. -//! -//! Library types never travel as themselves. Every payload is a DTO defined -//! here with an explicit conversion, so the wire format is versioned -//! independently of the library: a refactor inside `soulseek-rs-lib` cannot -//! silently change what a third-party script receives, and the compiler points -//! at this file when one would. -//! -//! The schemas published in `docs/openrpc.json` are derived from these types -//! (see the tests at the bottom), which is what keeps the advertised contract -//! and the running daemon in agreement. - -use crate::output::Exit; -use serde::{Deserialize, Serialize}; -use soulseek_rs::types::{ - Download, DownloadMetadata, UserPresence, UserStats, UserStatus, -}; -use soulseek_rs::{ - DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, - SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage, -}; -use std::collections::HashMap; - -/// Bumped when a change would break a client written against the old shape. -/// Sent in the `auth` reply so a mismatch is caught at connect time rather -/// than as a confusing failure three calls later. -pub const PROTOCOL_VERSION: u32 = 1; - -/// The OpenRPC document, served by `rpc.discover` so a generator can point at -/// a running daemon instead of needing this repository. -pub const OPENRPC: &str = include_str!("../../../docs/openrpc.json"); - -// --------------------------------------------------------------------------- -// JSON-RPC envelope -// --------------------------------------------------------------------------- - -/// A request from a client. `id` is absent for a notification, which by -/// JSON-RPC rule gets no reply. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Request { - #[serde(default = "jsonrpc_version")] - pub jsonrpc: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - pub method: String, - #[serde(default)] - pub params: serde_json::Value, -} - -/// A reply. Exactly one of `result`/`error` is set; `id` echoes the request, -/// and is absent on a daemon-initiated notification. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Response { - #[serde(default = "jsonrpc_version")] - pub jsonrpc: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub method: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -fn jsonrpc_version() -> String { - "2.0".to_string() -} - -/// A failure, carrying the exit code the same command would have produced -/// locally so a remote run exits exactly as a local one does. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct RpcError { - pub code: i32, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ErrorData { - /// The `Exit` discriminant this failure maps to. - pub exit: u8, -} - -/// Application errors all share one code; the exit status in `data` is what a -/// caller branches on. -pub const CODE_APPLICATION: i32 = -32000; -pub const CODE_PARSE: i32 = -32700; -pub const CODE_INVALID_REQUEST: i32 = -32600; -pub const CODE_METHOD_NOT_FOUND: i32 = -32601; -pub const CODE_INVALID_PARAMS: i32 = -32602; - -impl RpcError { - #[must_use] - pub fn new(code: i32, message: impl Into) -> Self { - Self { - code, - message: message.into(), - data: None, - } - } - - /// An application failure that should reproduce `exit` on the client. - #[must_use] - pub fn application(exit: Exit, message: impl Into) -> Self { - Self { - code: CODE_APPLICATION, - message: message.into(), - data: Some(ErrorData { exit: exit.code() }), - } - } - - /// The exit status this error asks the client to produce, defaulting to a - /// generic failure when the daemon did not say. - #[must_use] - pub fn exit(&self) -> Exit { - match self.data.as_ref().map(|data| data.exit) { - Some(0) => Exit::Ok, - Some(2) => Exit::Usage, - Some(3) => Exit::Connection, - Some(4) => Exit::NoResults, - Some(5) => Exit::Timeout, - Some(6) => Exit::Transfer, - Some(7) => Exit::SessionLost, - _ => Exit::Failure, - } - } -} - -impl Response { - #[must_use] - pub fn result(id: Option, value: serde_json::Value) -> Self { - Self { - jsonrpc: jsonrpc_version(), - id, - method: None, - params: None, - result: Some(value), - error: None, - } - } - - #[must_use] - pub fn failure(id: Option, error: RpcError) -> Self { - Self { - jsonrpc: jsonrpc_version(), - id, - method: None, - params: None, - result: None, - error: Some(error), - } - } - - /// A daemon-initiated event: no id, so no reply is expected. - #[must_use] - pub fn notification(method: &str, params: serde_json::Value) -> Self { - Self { - jsonrpc: jsonrpc_version(), - id: None, - method: Some(method.to_string()), - params: Some(params), - result: None, - error: None, - } - } -} - -// --------------------------------------------------------------------------- -// Handshake -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct AuthParams { - /// Required on TCP. Omitted on a Unix socket, where the socket's own - /// permissions already prove the caller is this user. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token: Option, - #[serde(default)] - pub protocol: u32, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct AuthResult { - pub protocol: u32, - pub daemon_version: String, - /// The Soulseek account the daemon is logged in as. - pub username: String, -} - -// --------------------------------------------------------------------------- -// Data types -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct FileDto { - pub name: String, - pub size: u64, - /// Soulseek attribute codes (0 = bitrate, 1 = duration in seconds). - pub attribs: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SearchResultDto { - pub token: u32, - pub username: String, - pub slots: u8, - pub speed: u32, - pub files: Vec, -} - -impl From<&SearchResult> for SearchResultDto { - fn from(result: &SearchResult) -> Self { - Self { - token: result.token, - username: result.username.clone(), - slots: result.slots, - speed: result.speed, - files: result - .files - .iter() - .map(|file| FileDto { - name: file.name.clone(), - size: file.size, - attribs: file.attribs.clone(), - }) - .collect(), - } - } -} - -impl From for SearchResult { - fn from(dto: SearchResultDto) -> Self { - Self { - token: dto.token, - slots: dto.slots, - speed: dto.speed, - files: dto - .files - .into_iter() - .map(|file| File { - username: dto.username.clone(), - name: file.name, - size: file.size, - attribs: file.attribs, - }) - .collect(), - username: dto.username, - } - } -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[serde(tag = "state", rename_all = "snake_case")] -pub enum DownloadStatusDto { - Queued, - InProgress { - bytes_downloaded: u64, - total_bytes: u64, - speed_bytes_per_sec: f64, - }, - Paused { - bytes_downloaded: u64, - total_bytes: u64, - }, - Completed, - Failed { - reason: Option, - }, - TimedOut, -} - -impl From<&DownloadStatus> for DownloadStatusDto { - fn from(status: &DownloadStatus) -> Self { - match status { - DownloadStatus::Queued => Self::Queued, - DownloadStatus::InProgress { - bytes_downloaded, - total_bytes, - speed_bytes_per_sec, - } => Self::InProgress { - bytes_downloaded: *bytes_downloaded, - total_bytes: *total_bytes, - speed_bytes_per_sec: *speed_bytes_per_sec, - }, - DownloadStatus::Paused { - bytes_downloaded, - total_bytes, - } => Self::Paused { - bytes_downloaded: *bytes_downloaded, - total_bytes: *total_bytes, - }, - DownloadStatus::Completed => Self::Completed, - DownloadStatus::Failed(reason) => Self::Failed { - reason: reason.clone(), - }, - DownloadStatus::TimedOut => Self::TimedOut, - } - } -} - -impl From for DownloadStatus { - fn from(dto: DownloadStatusDto) -> Self { - match dto { - DownloadStatusDto::Queued => Self::Queued, - DownloadStatusDto::InProgress { - bytes_downloaded, - total_bytes, - speed_bytes_per_sec, - } => Self::InProgress { - bytes_downloaded, - total_bytes, - speed_bytes_per_sec, - }, - DownloadStatusDto::Paused { - bytes_downloaded, - total_bytes, - } => Self::Paused { - bytes_downloaded, - total_bytes, - }, - DownloadStatusDto::Completed => Self::Completed, - DownloadStatusDto::Failed { reason } => Self::Failed(reason), - DownloadStatusDto::TimedOut => Self::TimedOut, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DownloadMetadataDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bitrate: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub length_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub peer_upload_speed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub peer_free_slots: Option, -} - -impl From<&DownloadMetadata> for DownloadMetadataDto { - fn from(metadata: &DownloadMetadata) -> Self { - Self { - bitrate: metadata.bitrate, - length_seconds: metadata.length_seconds, - peer_upload_speed: metadata.peer_upload_speed, - peer_free_slots: metadata.peer_free_slots, - } - } -} - -impl From for DownloadMetadata { - fn from(dto: DownloadMetadataDto) -> Self { - Self { - bitrate: dto.bitrate, - length_seconds: dto.length_seconds, - peer_upload_speed: dto.peer_upload_speed, - peer_free_slots: dto.peer_free_slots, - } - } -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DownloadDto { - pub username: String, - pub filename: String, - pub token: u32, - pub size: u64, - pub download_directory: String, - pub status: DownloadStatusDto, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub queue_position: Option, - #[serde(default)] - pub metadata: DownloadMetadataDto, -} - -impl From<&Download> for DownloadDto { - fn from(download: &Download) -> Self { - Self { - username: download.username.clone(), - filename: download.filename.clone(), - token: download.token, - size: download.size, - download_directory: download.download_directory.clone(), - status: DownloadStatusDto::from(&download.status), - queue_position: download.queue_position, - metadata: DownloadMetadataDto::from(&download.metadata), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[serde(tag = "state", rename_all = "snake_case")] -pub enum UploadStatusDto { - /// Waiting for a slot, at this 1-based place in the queue. - Queued { place: u32 }, - InProgress, - Completed, - Cancelled, - Failed { reason: String }, -} - -impl From<&UploadStatus> for UploadStatusDto { - fn from(status: &UploadStatus) -> Self { - match status { - UploadStatus::Queued(place) => Self::Queued { place: *place }, - UploadStatus::InProgress => Self::InProgress, - UploadStatus::Completed => Self::Completed, - UploadStatus::Cancelled => Self::Cancelled, - UploadStatus::Failed(reason) => Self::Failed { - reason: reason.clone(), - }, - } - } -} - -impl From for UploadStatus { - fn from(dto: UploadStatusDto) -> Self { - match dto { - UploadStatusDto::Queued { place } => Self::Queued(place), - UploadStatusDto::InProgress => Self::InProgress, - UploadStatusDto::Completed => Self::Completed, - UploadStatusDto::Cancelled => Self::Cancelled, - UploadStatusDto::Failed { reason } => Self::Failed(reason), - } - } -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UploadInfoDto { - pub username: String, - pub filename: String, - pub size: u64, - pub bytes_sent: u64, - pub status: UploadStatusDto, - pub speed_bytes_per_sec: f64, -} - -impl From<&UploadInfo> for UploadInfoDto { - fn from(upload: &UploadInfo) -> Self { - Self { - username: upload.username.clone(), - filename: upload.filename.clone(), - size: upload.size, - bytes_sent: upload.bytes_sent, - status: UploadStatusDto::from(&upload.status), - speed_bytes_per_sec: upload.speed_bytes_per_sec, - } - } -} - -impl From for UploadInfo { - fn from(dto: UploadInfoDto) -> Self { - Self { - username: dto.username, - filename: dto.filename, - size: dto.size, - bytes_sent: dto.bytes_sent, - status: dto.status.into(), - speed_bytes_per_sec: dto.speed_bytes_per_sec, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct RoomInfoDto { - pub name: String, - pub user_count: u32, -} - -impl From<&RoomInfo> for RoomInfoDto { - fn from(room: &RoomInfo) -> Self { - Self { - name: room.name.clone(), - user_count: room.user_count, - } - } -} - -impl From for RoomInfo { - fn from(dto: RoomInfoDto) -> Self { - Self { - name: dto.name, - user_count: dto.user_count, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[serde(tag = "event", rename_all = "snake_case")] -pub enum RoomEventDto { - List { - rooms: Vec, - }, - Joined { - room: String, - users: Vec, - }, - Left { - room: String, - }, - Message { - room: String, - username: String, - message: String, - }, - UserJoined { - room: String, - username: String, - }, - UserLeft { - room: String, - username: String, - }, -} - -impl From<&RoomEvent> for RoomEventDto { - fn from(event: &RoomEvent) -> Self { - match event { - RoomEvent::List(rooms) => Self::List { - rooms: rooms.iter().map(RoomInfoDto::from).collect(), - }, - RoomEvent::Joined { room, users } => Self::Joined { - room: room.clone(), - users: users.clone(), - }, - RoomEvent::Left { room } => Self::Left { room: room.clone() }, - RoomEvent::Message { - room, - username, - message, - } => Self::Message { - room: room.clone(), - username: username.clone(), - message: message.clone(), - }, - RoomEvent::UserJoined { room, username } => Self::UserJoined { - room: room.clone(), - username: username.clone(), - }, - RoomEvent::UserLeft { room, username } => Self::UserLeft { - room: room.clone(), - username: username.clone(), - }, - } - } -} - -impl From for RoomEvent { - fn from(dto: RoomEventDto) -> Self { - match dto { - RoomEventDto::List { rooms } => { - Self::List(rooms.into_iter().map(RoomInfo::from).collect()) - } - RoomEventDto::Joined { room, users } => Self::Joined { room, users }, - RoomEventDto::Left { room } => Self::Left { room }, - RoomEventDto::Message { - room, - username, - message, - } => Self::Message { - room, - username, - message, - }, - RoomEventDto::UserJoined { room, username } => { - Self::UserJoined { room, username } - } - RoomEventDto::UserLeft { room, username } => { - Self::UserLeft { room, username } - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UserPresenceDto { - /// `online`, `away`, or `offline`. - pub status: String, - pub privileged: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UserStatsDto { - pub average_speed: u32, - pub shared_files: u32, - pub shared_folders: u32, -} - -/// Each half is `None` until its reply lands, so a consumer can tell "the -/// server said offline" from "the server has not answered yet". -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UserInfoDto { - pub username: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub presence: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stats: Option, -} - -impl From<&UserInfo> for UserInfoDto { - fn from(info: &UserInfo) -> Self { - Self { - username: info.username.clone(), - presence: info.presence.map(|presence| UserPresenceDto { - status: presence.status.to_string(), - privileged: presence.privileged, - }), - stats: info.stats.map(|stats| UserStatsDto { - average_speed: stats.average_speed, - shared_files: stats.shared_files, - shared_folders: stats.shared_folders, - }), - } - } -} - -impl From for UserInfo { - fn from(dto: UserInfoDto) -> Self { - let mut info = Self::pending(dto.username); - info.presence = dto.presence.map(|presence| UserPresence { - status: match presence.status.as_str() { - "online" => UserStatus::Online, - "away" => UserStatus::Away, - _ => UserStatus::Offline, - }, - privileged: presence.privileged, - }); - info.stats = dto.stats.map(|stats| UserStats { - average_speed: stats.average_speed, - shared_files: stats.shared_files, - shared_folders: stats.shared_folders, - }); - info - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SharedDirectoryDto { - pub name: String, - /// Basename and size; join with the directory to get a downloadable path. - pub files: Vec<(String, u64)>, -} - -impl From<&SharedDirectory> for SharedDirectoryDto { - fn from(directory: &SharedDirectory) -> Self { - Self { - name: directory.name.clone(), - files: directory.files.clone(), - } - } -} - -impl From for SharedDirectory { - fn from(dto: SharedDirectoryDto) -> Self { - Self { - name: dto.name, - files: dto.files, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UserMessageDto { - pub id: u32, - pub timestamp: u32, - pub username: String, - pub message: String, - pub new_message: bool, -} - -impl From<&UserMessage> for UserMessageDto { - fn from(message: &UserMessage) -> Self { - Self { - id: message.id(), - timestamp: message.timestamp(), - username: message.username().to_string(), - message: message.message().to_string(), - new_message: message.is_new(), - } - } -} - -impl From for UserMessage { - fn from(dto: UserMessageDto) -> Self { - Self::new( - dto.id, - dto.timestamp, - dto.username, - dto.message, - dto.new_message, - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -pub enum SessionLossDto { - Displaced, - Disconnected, -} - -impl From for SessionLossDto { - fn from(loss: SessionLoss) -> Self { - match loss { - SessionLoss::Displaced => Self::Displaced, - SessionLoss::Disconnected => Self::Disconnected, - } - } -} - -impl From for SessionLoss { - fn from(dto: SessionLossDto) -> Self { - match dto { - SessionLossDto::Displaced => Self::Displaced, - SessionLossDto::Disconnected => Self::Disconnected, - } - } -} - -/// What the daemon is and what its session is doing, from `daemon.status`. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DaemonStatus { - pub username: String, - pub server: String, - pub daemon_version: String, - pub protocol: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub listen_port: Option, - pub shared_folders: u32, - pub shared_files: u32, - pub download_dir: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_loss: Option, - /// Control connections currently attached. - pub clients: usize, - pub uptime_secs: u64, -} - -/// What the share index holds, from `shares.status` / `shares.reindex`. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SharesStatus { - pub folders: u32, - pub files: u32, - pub directories: Vec, -} - -// --------------------------------------------------------------------------- -// Method parameters -// --------------------------------------------------------------------------- - -/// A void result. JSON-RPC allows a null result, but an object leaves room to -/// add a field later without changing the shape a client already parses. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct Ack { - pub ok: bool, -} - -impl Ack { - pub const OK: Self = Self { ok: true }; -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct QueryParams { - pub query: String, -} - -/// Names one transfer, which is how both directions are addressed. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct TransferRef { - pub username: String, - pub filename: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct RoomRef { - pub room: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UserRef { - pub username: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SayParams { - pub room: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct MessageParams { - pub username: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DownloadStartParams { - pub username: String, - pub filename: String, - #[serde(default)] - pub size: u64, - /// Where the file lands *on the daemon's* filesystem. Defaults to the - /// daemon's configured download directory, which is the only sensible - /// choice for a remote caller that cannot see that filesystem. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub download_directory: Option, - #[serde(default)] - pub metadata: DownloadMetadataDto, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SlotsParams { - pub slots: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DirectoriesParams { - pub directories: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SearchResults { - pub results: Vec, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct Downloads { - pub downloads: Vec, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DownloadStarted { - pub download: DownloadDto, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct Uploads { - pub uploads: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct Members { - pub users: Vec, -} - -/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a -/// direction, because a conversation the daemon collected includes what this -/// account sent as well as what it received. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct ChatMessageDto { - /// The other party, whichever way the message went. - pub peer: String, - pub outgoing: bool, - pub text: String, - /// Unix seconds. - pub at: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct Messages { - pub messages: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct UserResult { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct Seconds { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seconds: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct IntervalSeconds { - pub seconds: u64, -} - -// --------------------------------------------------------------------------- -// Event notifications -// --------------------------------------------------------------------------- - -/// Declare a wire vocabulary once: the enum, the complete list, and the -/// name mapping in both directions. -/// -/// Everything that consumes one of these matches on the enum, so the match is -/// exhaustive and adding a line here is a compile error everywhere the new -/// member still has to be handled — the dispatcher, the published schema, the -/// remote client. A `&[&str]` table checked by a test would only fail after -/// the fact, and only if someone remembered to write the test. -macro_rules! wire_vocabulary { - ($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => { - $(#[$meta])* - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub enum $name { - $($variant),* - } - - impl $name { - /// Every member, in published order. - pub const ALL: &'static [Self] = &[$(Self::$variant),*]; - - #[must_use] - pub const fn wire_name(self) -> &'static str { - match self { - $(Self::$variant => $wire),* - } - } - - #[must_use] - pub fn from_wire(name: &str) -> Option { - match name { - $($wire => Some(Self::$variant),)* - _ => None, - } - } - } - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.wire_name()) - } - } - }; -} - -wire_vocabulary! { - /// Every method the daemon answers. - Method { - Auth => "auth", - DaemonStatus => "daemon.status", - DaemonStop => "daemon.stop", - RpcDiscover => "rpc.discover", - SearchStart => "search.start", - SearchResults => "search.results", - SearchWishlist => "search.wishlist", - SearchWishlistInterval => "search.wishlist_interval", - DownloadStart => "download.start", - DownloadList => "download.list", - DownloadPause => "download.pause", - DownloadResume => "download.resume", - DownloadRemove => "download.remove", - DownloadRemoveQueued => "download.remove_queued", - UploadList => "upload.list", - UploadCancel => "upload.cancel", - UploadSlots => "upload.slots", - PrivilegesCheck => "privileges.check", - PrivilegesOwn => "privileges.own", - RoomListRequest => "room.list_request", - RoomJoin => "room.join", - RoomLeave => "room.leave", - RoomSay => "room.say", - RoomMembers => "room.members", - MessageSend => "message.send", - MessageHistory => "message.history", - BrowseUser => "browse.user", - UserRequest => "user.request", - UserInfoOf => "user.info", - SharesStatusOf => "shares.status", - SharesSet => "shares.set", - SharesReindex => "shares.reindex", - } -} - -wire_vocabulary! { - /// Events the daemon pushes without being asked. - /// - /// These exist because the library's `take_*` accessors are destructive: - /// with two clients attached, whichever drained first would steal the - /// others' events. The daemon is the single drainer and fans out. - Event { - Room => "event.room", - Message => "event.message", - Upload => "event.upload", - DownloadStatus => "event.download_status", - Browse => "event.browse", - SessionLoss => "event.session_loss", - } -} - -/// A peer answered a browse request. Carried whole rather than as a "go ask -/// for it" nudge, because the daemon's own copy is consumed when it drains. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct BrowseEvent { - pub username: String, - pub directories: Vec, -} - -/// One step in a transfer this client started. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct DownloadStatusEvent { - pub username: String, - pub filename: String, - pub status: DownloadStatusDto, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[cfg_attr(test, derive(schemars::JsonSchema))] -pub struct SessionLossEvent { - pub loss: SessionLossDto, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_search_result_survives_the_round_trip() { - let mut attribs = HashMap::new(); - attribs.insert(0, 320); - attribs.insert(1, 210); - let original = SearchResult { - token: 7, - slots: 2, - speed: 900, - username: "bob".into(), - files: vec![File { - username: "bob".into(), - name: "@@x\\a.mp3".into(), - size: 4096, - attribs, - }], - }; - - let json = serde_json::to_string(&SearchResultDto::from(&original)) - .expect("a DTO serializes"); - let back: SearchResult = - serde_json::from_str::(&json).unwrap().into(); - - assert_eq!(back.token, original.token); - assert_eq!(back.username, original.username); - assert_eq!(back.slots, original.slots); - assert_eq!(back.speed, original.speed); - assert_eq!(back.files.len(), 1); - assert_eq!(back.files[0].name, "@@x\\a.mp3"); - assert_eq!(back.files[0].size, 4096); - assert_eq!(back.files[0].attribs.get(&0), Some(&320)); - // The per-file username is carried by the parent result, so it has to - // be restored rather than lost. - assert_eq!(back.files[0].username, "bob"); - } - - #[test] - fn every_download_status_survives_the_round_trip() { - let statuses = [ - DownloadStatus::Queued, - DownloadStatus::InProgress { - bytes_downloaded: 10, - total_bytes: 100, - speed_bytes_per_sec: 1.5, - }, - DownloadStatus::Paused { - bytes_downloaded: 10, - total_bytes: 100, - }, - DownloadStatus::Completed, - DownloadStatus::Failed(Some("gone".into())), - DownloadStatus::Failed(None), - DownloadStatus::TimedOut, - ]; - for status in &statuses { - let dto = DownloadStatusDto::from(status); - let json = serde_json::to_string(&dto).unwrap(); - let back: DownloadStatusDto = serde_json::from_str(&json).unwrap(); - assert_eq!(dto, back, "{status:?} must survive the wire"); - assert_eq!( - DownloadStatusDto::from(&DownloadStatus::from(back)), - dto, - "{status:?} must convert back to the same library value" - ); - } - } - - #[test] - fn every_upload_status_survives_the_round_trip() { - let statuses = [ - UploadStatus::Queued(3), - UploadStatus::InProgress, - UploadStatus::Completed, - UploadStatus::Cancelled, - UploadStatus::Failed("nope".into()), - ]; - for status in &statuses { - let dto = UploadStatusDto::from(status); - let json = serde_json::to_string(&dto).unwrap(); - let back: UploadStatus = - serde_json::from_str::(&json).unwrap().into(); - assert_eq!(&back, status); - } - } - - #[test] - fn every_room_event_survives_the_round_trip() { - let events = [ - RoomEvent::List(vec![RoomInfo { - name: "lobby".into(), - user_count: 42, - }]), - RoomEvent::Joined { - room: "lobby".into(), - users: vec!["bob".into()], - }, - RoomEvent::Left { - room: "lobby".into(), - }, - RoomEvent::Message { - room: "lobby".into(), - username: "bob".into(), - message: "hi".into(), - }, - RoomEvent::UserJoined { - room: "lobby".into(), - username: "sue".into(), - }, - RoomEvent::UserLeft { - room: "lobby".into(), - username: "sue".into(), - }, - ]; - for event in &events { - let json = serde_json::to_string(&RoomEventDto::from(event)).unwrap(); - let back: RoomEvent = - serde_json::from_str::(&json).unwrap().into(); - assert_eq!(&back, event); - } - } - - #[test] - fn a_partly_answered_user_stays_partly_answered() { - // "the server has not said" must not arrive as "offline, shares - // nothing": that is the difference between waiting and giving up. - let pending = UserInfo::pending("bob".into()); - let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap(); - let back: UserInfo = - serde_json::from_str::(&json).unwrap().into(); - assert_eq!(back.username, "bob"); - assert!(back.presence.is_none()); - assert!(back.stats.is_none()); - assert!(!back.is_complete()); - } - - #[test] - fn a_complete_user_survives_the_round_trip() { - let mut info = UserInfo::pending("bob".into()); - info.presence = Some(UserPresence { - status: UserStatus::Away, - privileged: true, - }); - info.stats = Some(UserStats { - average_speed: 500, - shared_files: 10, - shared_folders: 2, - }); - - let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap(); - let back: UserInfo = - serde_json::from_str::(&json).unwrap().into(); - assert_eq!(back, info); - } - - #[test] - fn a_private_message_survives_the_round_trip() { - let message = UserMessage::new(9, 1234, "bob".into(), "hi".into(), true); - let json = serde_json::to_string(&UserMessageDto::from(&message)).unwrap(); - let back: UserMessage = - serde_json::from_str::(&json).unwrap().into(); - assert_eq!(back.id(), 9); - assert_eq!(back.timestamp(), 1234); - assert_eq!(back.username(), "bob"); - assert_eq!(back.message(), "hi"); - } - - #[test] - fn a_shared_listing_survives_the_round_trip() { - let directory = SharedDirectory { - name: "@@x\\Music".into(), - files: vec![("a.mp3".into(), 4096)], - }; - let json = - serde_json::to_string(&SharedDirectoryDto::from(&directory)).unwrap(); - let back: SharedDirectory = - serde_json::from_str::(&json).unwrap().into(); - assert_eq!(back.name, directory.name); - assert_eq!(back.files, directory.files); - } - - #[test] - fn an_error_carries_the_exit_code_a_local_run_would_have_produced() { - for exit in [ - Exit::Usage, - Exit::Connection, - Exit::NoResults, - Exit::Timeout, - Exit::Transfer, - Exit::SessionLost, - ] { - let error = RpcError::application(exit, "nope"); - let json = serde_json::to_string(&error).unwrap(); - let back: RpcError = serde_json::from_str(&json).unwrap(); - assert_eq!(back.exit(), exit); - } - } - - #[test] - fn an_error_without_an_exit_code_is_a_generic_failure() { - let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method"); - assert_eq!(error.exit(), Exit::Failure); - } - - #[test] - fn a_request_parses_without_optional_fields() { - let request: Request = - serde_json::from_str(r#"{"jsonrpc":"2.0","method":"daemon.status"}"#) - .expect("params and id are optional"); - assert_eq!(request.method, "daemon.status"); - assert!(request.id.is_none()); - assert!(request.params.is_null()); - } - - #[test] - fn a_notification_carries_no_id_so_no_reply_is_expected() { - let notification = - Response::notification("event.room", serde_json::json!({"a": 1})); - let json = serde_json::to_string(¬ification).unwrap(); - assert!(!json.contains("\"id\""), "{json}"); - assert!(json.contains("\"method\":\"event.room\""), "{json}"); - } - - #[test] - fn a_response_serializes_exactly_one_of_result_and_error() { - let ok = serde_json::to_string(&Response::result( - Some(serde_json::json!(1)), - serde_json::json!({}), - )) - .unwrap(); - assert!(ok.contains("\"result\"")); - assert!(!ok.contains("\"error\"")); - - let failed = serde_json::to_string(&Response::failure( - Some(serde_json::json!(1)), - RpcError::application(Exit::NoResults, "nothing"), - )) - .unwrap(); - assert!(failed.contains("\"error\"")); - assert!(!failed.contains("\"result\"")); - } -} diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs new file mode 100644 index 0000000..c5f513d --- /dev/null +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -0,0 +1,612 @@ +//! The data types that cross the wire. +//! +//! Library types never travel as themselves. Each has an explicit DTO and a +//! mechanical conversion, so the published shape is versioned independently of +//! `soulseek-rs-lib` — a refactor there cannot silently change what a +//! third-party script receives, and the compiler points here when one would. + +use serde::{Deserialize, Serialize}; +use soulseek_rs::types::{ + Download, DownloadMetadata, UserPresence, UserStats, UserStatus, +}; +use soulseek_rs::{ + DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, + SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage, +}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct AuthParams { + /// Required on TCP. Omitted on a Unix socket, where the socket's own + /// permissions already prove the caller is this user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default)] + pub protocol: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct AuthResult { + pub protocol: u32, + pub daemon_version: String, + /// The Soulseek account the daemon is logged in as. + pub username: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct FileDto { + pub name: String, + pub size: u64, + /// Soulseek attribute codes (0 = bitrate, 1 = duration in seconds). + pub attribs: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchResultDto { + pub token: u32, + pub username: String, + pub slots: u8, + pub speed: u32, + pub files: Vec, +} + +impl From<&SearchResult> for SearchResultDto { + fn from(result: &SearchResult) -> Self { + Self { + token: result.token, + username: result.username.clone(), + slots: result.slots, + speed: result.speed, + files: result + .files + .iter() + .map(|file| FileDto { + name: file.name.clone(), + size: file.size, + attribs: file.attribs.clone(), + }) + .collect(), + } + } +} + +impl From for SearchResult { + fn from(dto: SearchResultDto) -> Self { + Self { + token: dto.token, + slots: dto.slots, + speed: dto.speed, + files: dto + .files + .into_iter() + .map(|file| File { + username: dto.username.clone(), + name: file.name, + size: file.size, + attribs: file.attribs, + }) + .collect(), + username: dto.username, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum DownloadStatusDto { + Queued, + InProgress { + bytes_downloaded: u64, + total_bytes: u64, + speed_bytes_per_sec: f64, + }, + Paused { + bytes_downloaded: u64, + total_bytes: u64, + }, + Completed, + Failed { + reason: Option, + }, + TimedOut, +} + +impl From<&DownloadStatus> for DownloadStatusDto { + fn from(status: &DownloadStatus) -> Self { + match status { + DownloadStatus::Queued => Self::Queued, + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => Self::InProgress { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + speed_bytes_per_sec: *speed_bytes_per_sec, + }, + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => Self::Paused { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + }, + DownloadStatus::Completed => Self::Completed, + DownloadStatus::Failed(reason) => Self::Failed { + reason: reason.clone(), + }, + DownloadStatus::TimedOut => Self::TimedOut, + } + } +} + +impl From for DownloadStatus { + fn from(dto: DownloadStatusDto) -> Self { + match dto { + DownloadStatusDto::Queued => Self::Queued, + DownloadStatusDto::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => Self::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + }, + DownloadStatusDto::Paused { + bytes_downloaded, + total_bytes, + } => Self::Paused { + bytes_downloaded, + total_bytes, + }, + DownloadStatusDto::Completed => Self::Completed, + DownloadStatusDto::Failed { reason } => Self::Failed(reason), + DownloadStatusDto::TimedOut => Self::TimedOut, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadMetadataDto { + #[serde(default)] + pub bitrate: Option, + #[serde(default)] + pub length_seconds: Option, + #[serde(default)] + pub peer_upload_speed: Option, + #[serde(default)] + pub peer_free_slots: Option, +} + +impl From<&DownloadMetadata> for DownloadMetadataDto { + fn from(metadata: &DownloadMetadata) -> Self { + Self { + bitrate: metadata.bitrate, + length_seconds: metadata.length_seconds, + peer_upload_speed: metadata.peer_upload_speed, + peer_free_slots: metadata.peer_free_slots, + } + } +} + +impl From for DownloadMetadata { + fn from(dto: DownloadMetadataDto) -> Self { + Self { + bitrate: dto.bitrate, + length_seconds: dto.length_seconds, + peer_upload_speed: dto.peer_upload_speed, + peer_free_slots: dto.peer_free_slots, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadDto { + pub username: String, + pub filename: String, + pub token: u32, + pub size: u64, + pub download_directory: String, + pub status: DownloadStatusDto, + #[serde(default)] + pub queue_position: Option, + #[serde(default)] + pub metadata: DownloadMetadataDto, +} + +impl From<&Download> for DownloadDto { + fn from(download: &Download) -> Self { + Self { + username: download.username.clone(), + filename: download.filename.clone(), + token: download.token, + size: download.size, + download_directory: download.download_directory.clone(), + status: DownloadStatusDto::from(&download.status), + queue_position: download.queue_position, + metadata: DownloadMetadataDto::from(&download.metadata), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum UploadStatusDto { + /// Waiting for a slot, at this 1-based place in the queue. + Queued { + place: u32, + }, + InProgress, + Completed, + Cancelled, + Failed { + reason: String, + }, +} + +impl From<&UploadStatus> for UploadStatusDto { + fn from(status: &UploadStatus) -> Self { + match status { + UploadStatus::Queued(place) => Self::Queued { place: *place }, + UploadStatus::InProgress => Self::InProgress, + UploadStatus::Completed => Self::Completed, + UploadStatus::Cancelled => Self::Cancelled, + UploadStatus::Failed(reason) => Self::Failed { + reason: reason.clone(), + }, + } + } +} + +impl From for UploadStatus { + fn from(dto: UploadStatusDto) -> Self { + match dto { + UploadStatusDto::Queued { place } => Self::Queued(place), + UploadStatusDto::InProgress => Self::InProgress, + UploadStatusDto::Completed => Self::Completed, + UploadStatusDto::Cancelled => Self::Cancelled, + UploadStatusDto::Failed { reason } => Self::Failed(reason), + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UploadInfoDto { + pub username: String, + pub filename: String, + pub size: u64, + pub bytes_sent: u64, + pub status: UploadStatusDto, + pub speed_bytes_per_sec: f64, +} + +impl From<&UploadInfo> for UploadInfoDto { + fn from(upload: &UploadInfo) -> Self { + Self { + username: upload.username.clone(), + filename: upload.filename.clone(), + size: upload.size, + bytes_sent: upload.bytes_sent, + status: UploadStatusDto::from(&upload.status), + speed_bytes_per_sec: upload.speed_bytes_per_sec, + } + } +} + +impl From for UploadInfo { + fn from(dto: UploadInfoDto) -> Self { + Self { + username: dto.username, + filename: dto.filename, + size: dto.size, + bytes_sent: dto.bytes_sent, + status: dto.status.into(), + speed_bytes_per_sec: dto.speed_bytes_per_sec, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomInfoDto { + pub name: String, + pub user_count: u32, +} + +impl From<&RoomInfo> for RoomInfoDto { + fn from(room: &RoomInfo) -> Self { + Self { + name: room.name.clone(), + user_count: room.user_count, + } + } +} + +impl From for RoomInfo { + fn from(dto: RoomInfoDto) -> Self { + Self { + name: dto.name, + user_count: dto.user_count, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum RoomEventDto { + List { + rooms: Vec, + }, + Joined { + room: String, + users: Vec, + }, + Left { + room: String, + }, + Message { + room: String, + username: String, + message: String, + }, + UserJoined { + room: String, + username: String, + }, + UserLeft { + room: String, + username: String, + }, +} + +impl From<&RoomEvent> for RoomEventDto { + fn from(event: &RoomEvent) -> Self { + match event { + RoomEvent::List(rooms) => Self::List { + rooms: rooms.iter().map(RoomInfoDto::from).collect(), + }, + RoomEvent::Joined { room, users } => Self::Joined { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::Left { room } => Self::Left { room: room.clone() }, + RoomEvent::Message { + room, + username, + message, + } => Self::Message { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::UserJoined { room, username } => Self::UserJoined { + room: room.clone(), + username: username.clone(), + }, + RoomEvent::UserLeft { room, username } => Self::UserLeft { + room: room.clone(), + username: username.clone(), + }, + } + } +} + +impl From for RoomEvent { + fn from(dto: RoomEventDto) -> Self { + match dto { + RoomEventDto::List { rooms } => { + Self::List(rooms.into_iter().map(RoomInfo::from).collect()) + } + RoomEventDto::Joined { room, users } => { + Self::Joined { room, users } + } + RoomEventDto::Left { room } => Self::Left { room }, + RoomEventDto::Message { + room, + username, + message, + } => Self::Message { + room, + username, + message, + }, + RoomEventDto::UserJoined { room, username } => { + Self::UserJoined { room, username } + } + RoomEventDto::UserLeft { room, username } => { + Self::UserLeft { room, username } + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserPresenceDto { + /// `online`, `away`, or `offline`. + pub status: String, + pub privileged: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserStatsDto { + pub average_speed: u32, + pub shared_files: u32, + pub shared_folders: u32, +} + +/// Each half is `None` until its reply lands, so a consumer can tell "the +/// server said offline" from "the server has not answered yet". +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserInfoDto { + pub username: String, + #[serde(default)] + pub presence: Option, + #[serde(default)] + pub stats: Option, +} + +impl From<&UserInfo> for UserInfoDto { + fn from(info: &UserInfo) -> Self { + Self { + username: info.username.clone(), + presence: info.presence.map(|presence| UserPresenceDto { + status: presence.status.to_string(), + privileged: presence.privileged, + }), + stats: info.stats.map(|stats| UserStatsDto { + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + }), + } + } +} + +impl From for UserInfo { + fn from(dto: UserInfoDto) -> Self { + let mut info = Self::pending(dto.username); + info.presence = dto.presence.map(|presence| UserPresence { + status: match presence.status.as_str() { + "online" => UserStatus::Online, + "away" => UserStatus::Away, + _ => UserStatus::Offline, + }, + privileged: presence.privileged, + }); + info.stats = dto.stats.map(|stats| UserStats { + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + }); + info + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedDirectoryDto { + pub name: String, + /// Basename and size; join with the directory to get a downloadable path. + pub files: Vec<(String, u64)>, +} + +impl From<&SharedDirectory> for SharedDirectoryDto { + fn from(directory: &SharedDirectory) -> Self { + Self { + name: directory.name.clone(), + files: directory.files.clone(), + } + } +} + +impl From for SharedDirectory { + fn from(dto: SharedDirectoryDto) -> Self { + Self { + name: dto.name, + files: dto.files, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserMessageDto { + pub id: u32, + pub timestamp: u32, + pub username: String, + pub message: String, + pub new_message: bool, +} + +impl From<&UserMessage> for UserMessageDto { + fn from(message: &UserMessage) -> Self { + Self { + id: message.id(), + timestamp: message.timestamp(), + username: message.username().to_string(), + message: message.message().to_string(), + new_message: message.is_new(), + } + } +} + +impl From for UserMessage { + fn from(dto: UserMessageDto) -> Self { + Self::new( + dto.id, + dto.timestamp, + dto.username, + dto.message, + dto.new_message, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum SessionLossDto { + Displaced, + Disconnected, +} + +impl From for SessionLossDto { + fn from(loss: SessionLoss) -> Self { + match loss { + SessionLoss::Displaced => Self::Displaced, + SessionLoss::Disconnected => Self::Disconnected, + } + } +} + +impl From for SessionLoss { + fn from(dto: SessionLossDto) -> Self { + match dto { + SessionLossDto::Displaced => Self::Displaced, + SessionLossDto::Disconnected => Self::Disconnected, + } + } +} + +/// What the daemon is and what its session is doing, from `daemon.status`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DaemonStatus { + pub username: String, + pub server: String, + pub daemon_version: String, + pub protocol: u32, + #[serde(default)] + pub listen_port: Option, + pub shared_folders: u32, + pub shared_files: u32, + pub download_dir: String, + #[serde(default)] + pub session_loss: Option, + /// Control connections currently attached. + pub clients: usize, + pub uptime_secs: u64, +} + +/// What the share index holds, from `shares.status` / `shares.reindex`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharesStatus { + pub folders: u32, + pub files: u32, + pub directories: Vec, +} diff --git a/soulseek-rs/src/daemon/proto/envelope.rs b/soulseek-rs/src/daemon/proto/envelope.rs new file mode 100644 index 0000000..662d760 --- /dev/null +++ b/soulseek-rs/src/daemon/proto/envelope.rs @@ -0,0 +1,150 @@ +//! JSON-RPC 2.0 framing: what a request, a reply, and an error look like on +//! the wire, independent of the methods they carry. + +use crate::output::Exit; +use serde::{Deserialize, Serialize}; + +/// A request from a client. `id` is absent for a notification, which by +/// JSON-RPC rule gets no reply. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Request { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// A reply. Exactly one of `result`/`error` is set; `id` echoes the request, +/// and is absent on a daemon-initiated notification. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Response { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +fn jsonrpc_version() -> String { + "2.0".to_string() +} + +/// A failure, carrying the exit code the same command would have produced +/// locally so a remote run exits exactly as a local one does. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpcError { + pub code: i32, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ErrorData { + /// The `Exit` discriminant this failure maps to. + pub exit: u8, +} + +/// Application errors all share one code; the exit status in `data` is what a +/// caller branches on. +pub const CODE_APPLICATION: i32 = -32000; +pub const CODE_PARSE: i32 = -32700; +pub const CODE_INVALID_REQUEST: i32 = -32600; +pub const CODE_METHOD_NOT_FOUND: i32 = -32601; +pub const CODE_INVALID_PARAMS: i32 = -32602; + +impl RpcError { + #[must_use] + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + /// An application failure that should reproduce `exit` on the client. + #[must_use] + pub fn application(exit: Exit, message: impl Into) -> Self { + Self { + code: CODE_APPLICATION, + message: message.into(), + data: Some(ErrorData { exit: exit.code() }), + } + } + + /// The exit status this error asks the client to produce, defaulting to a + /// generic failure when the daemon did not say. + #[must_use] + pub fn exit(&self) -> Exit { + match self.data.as_ref().map(|data| data.exit) { + Some(0) => Exit::Ok, + Some(2) => Exit::Usage, + Some(3) => Exit::Connection, + Some(4) => Exit::NoResults, + Some(5) => Exit::Timeout, + Some(6) => Exit::Transfer, + Some(7) => Exit::SessionLost, + _ => Exit::Failure, + } + } +} + +impl Response { + #[must_use] + pub fn result( + id: Option, + value: serde_json::Value, + ) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id, + method: None, + params: None, + result: Some(value), + error: None, + } + } + + /// A failed reply. + /// + /// An error always carries an `id` member, explicitly `null` when the + /// request was too malformed to have one. JSON-RPC requires it, and + /// without it a client following the "no id means a notification" rule + /// would drop the error and wait out its timeout for a reply that is + /// already there. + #[must_use] + pub fn failure(id: Option, error: RpcError) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id: Some(id.unwrap_or(serde_json::Value::Null)), + method: None, + params: None, + result: None, + error: Some(error), + } + } + + /// A daemon-initiated event: no id, so no reply is expected. + #[must_use] + pub fn notification(method: &str, params: serde_json::Value) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id: None, + method: Some(method.to_string()), + params: Some(params), + result: None, + error: None, + } + } +} diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs new file mode 100644 index 0000000..860f917 --- /dev/null +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -0,0 +1,176 @@ +//! What each method takes and returns, and what each pushed event carries. + +use super::dto::{ + DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto, + SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto, +}; +use serde::{Deserialize, Serialize}; + +/// A void result. JSON-RPC allows a null result, but an object leaves room to +/// add a field later without changing the shape a client already parses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Ack { + pub ok: bool, +} + +impl Ack { + pub const OK: Self = Self { ok: true }; +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct QueryParams { + pub query: String, +} + +/// Names one transfer, which is how both directions are addressed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct TransferRef { + pub username: String, + pub filename: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomRef { + pub room: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserRef { + pub username: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SayParams { + pub room: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct MessageParams { + pub username: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStartParams { + pub username: String, + pub filename: String, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub metadata: DownloadMetadataDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SlotsParams { + pub slots: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DirectoriesParams { + pub directories: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchResults { + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Downloads { + pub downloads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStarted { + pub download: DownloadDto, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Uploads { + pub uploads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Members { + pub users: Vec, +} + +/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a +/// direction, because a conversation the daemon collected includes what this +/// account sent as well as what it received. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct ChatMessageDto { + /// The other party, whichever way the message went. + pub peer: String, + pub outgoing: bool, + pub text: String, + /// Unix seconds. + pub at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Messages { + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserResult { + #[serde(default)] + pub user: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Seconds { + #[serde(default)] + pub seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct IntervalSeconds { + pub seconds: u64, +} + +/// A peer answered a browse request. Carried whole rather than as a "go ask +/// for it" nudge, because the daemon's own copy is consumed when it drains. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct BrowseEvent { + pub username: String, + pub directories: Vec, +} + +/// One step in a transfer this client started. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStatusEvent { + pub username: String, + pub filename: String, + pub status: DownloadStatusDto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SessionLossEvent { + pub loss: SessionLossDto, +} diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs new file mode 100644 index 0000000..bbe76e8 --- /dev/null +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -0,0 +1,402 @@ +//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it. +//! +//! Library types never travel as themselves. Every payload is a DTO defined +//! here with an explicit conversion, so the wire format is versioned +//! independently of the library: a refactor inside `soulseek-rs-lib` cannot +//! silently change what a third-party script receives, and the compiler points +//! at this file when one would. +//! +//! The schemas published in `docs/openrpc.json` are derived from these types +//! (see the tests at the bottom), which is what keeps the advertised contract +//! and the running daemon in agreement. + +/// Bumped when a change would break a client written against the old shape. +/// Sent in the `auth` reply so a mismatch is caught at connect time rather +/// than as a confusing failure three calls later. +pub const PROTOCOL_VERSION: u32 = 1; + +/// The OpenRPC document, served by `rpc.discover` so a generator can point at +/// a running daemon instead of needing this repository. +pub const OPENRPC: &str = include_str!("../../../../docs/openrpc.json"); + +mod dto; +mod envelope; +mod method; + +pub use dto::*; +pub use envelope::*; +pub use method::*; + +/// Declare a wire vocabulary once: the enum, the complete list, and the +/// name mapping in both directions. +/// +/// Everything that consumes one of these matches on the enum, so the match is +/// exhaustive and adding a line here is a compile error everywhere the new +/// member still has to be handled — the dispatcher, the published schema, the +/// remote client. A `&[&str]` table checked by a test would only fail after +/// the fact, and only if someone remembered to write the test. +macro_rules! wire_vocabulary { + ($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum $name { + $($variant),* + } + + impl $name { + /// Every member, in published order. + pub const ALL: &'static [Self] = &[$(Self::$variant),*]; + + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + $(Self::$variant => $wire),* + } + } + + #[must_use] + pub fn from_wire(name: &str) -> Option { + match name { + $($wire => Some(Self::$variant),)* + _ => None, + } + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.wire_name()) + } + } + }; +} + +wire_vocabulary! { + /// Every method the daemon answers. + Method { + Auth => "auth", + DaemonStatus => "daemon.status", + DaemonStop => "daemon.stop", + RpcDiscover => "rpc.discover", + SearchStart => "search.start", + SearchResults => "search.results", + SearchWishlist => "search.wishlist", + SearchWishlistInterval => "search.wishlist_interval", + DownloadStart => "download.start", + DownloadList => "download.list", + DownloadPause => "download.pause", + DownloadResume => "download.resume", + DownloadRemove => "download.remove", + DownloadRemoveQueued => "download.remove_queued", + UploadList => "upload.list", + UploadCancel => "upload.cancel", + UploadSlots => "upload.slots", + PrivilegesCheck => "privileges.check", + PrivilegesOwn => "privileges.own", + RoomListRequest => "room.list_request", + RoomJoin => "room.join", + RoomLeave => "room.leave", + RoomSay => "room.say", + RoomMembers => "room.members", + MessageSend => "message.send", + MessageHistory => "message.history", + BrowseUser => "browse.user", + UserRequest => "user.request", + UserInfoOf => "user.info", + SharesStatusOf => "shares.status", + SharesSet => "shares.set", + SharesReindex => "shares.reindex", + } +} + +wire_vocabulary! { + /// Events the daemon pushes without being asked. + /// + /// These exist because the library's `take_*` accessors are destructive: + /// with two clients attached, whichever drained first would steal the + /// others' events. The daemon is the single drainer and fans out. + Event { + Room => "event.room", + Message => "event.message", + Upload => "event.upload", + DownloadStatus => "event.download_status", + Browse => "event.browse", + SessionLoss => "event.session_loss", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::Exit; + use soulseek_rs::types::{UserPresence, UserStats, UserStatus}; + use soulseek_rs::{ + DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, + SharedDirectory, UploadStatus, UserInfo, UserMessage, + }; + use std::collections::HashMap; + + #[test] + fn a_search_result_survives_the_round_trip() { + let mut attribs = HashMap::new(); + attribs.insert(0, 320); + attribs.insert(1, 210); + let original = SearchResult { + token: 7, + slots: 2, + speed: 900, + username: "bob".into(), + files: vec![File { + username: "bob".into(), + name: "@@x\\a.mp3".into(), + size: 4096, + attribs, + }], + }; + + let json = serde_json::to_string(&SearchResultDto::from(&original)) + .expect("a DTO serializes"); + let back: SearchResult = serde_json::from_str::(&json) + .unwrap() + .into(); + + assert_eq!(back.token, original.token); + assert_eq!(back.username, original.username); + assert_eq!(back.slots, original.slots); + assert_eq!(back.speed, original.speed); + assert_eq!(back.files.len(), 1); + assert_eq!(back.files[0].name, "@@x\\a.mp3"); + assert_eq!(back.files[0].size, 4096); + assert_eq!(back.files[0].attribs.get(&0), Some(&320)); + // The per-file username is carried by the parent result, so it has to + // be restored rather than lost. + assert_eq!(back.files[0].username, "bob"); + } + + #[test] + fn every_download_status_survives_the_round_trip() { + let statuses = [ + DownloadStatus::Queued, + DownloadStatus::InProgress { + bytes_downloaded: 10, + total_bytes: 100, + speed_bytes_per_sec: 1.5, + }, + DownloadStatus::Paused { + bytes_downloaded: 10, + total_bytes: 100, + }, + DownloadStatus::Completed, + DownloadStatus::Failed(Some("gone".into())), + DownloadStatus::Failed(None), + DownloadStatus::TimedOut, + ]; + for status in &statuses { + let dto = DownloadStatusDto::from(status); + let json = serde_json::to_string(&dto).unwrap(); + let back: DownloadStatusDto = serde_json::from_str(&json).unwrap(); + assert_eq!(dto, back, "{status:?} must survive the wire"); + assert_eq!( + DownloadStatusDto::from(&DownloadStatus::from(back)), + dto, + "{status:?} must convert back to the same library value" + ); + } + } + + #[test] + fn every_upload_status_survives_the_round_trip() { + let statuses = [ + UploadStatus::Queued(3), + UploadStatus::InProgress, + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".into()), + ]; + for status in &statuses { + let dto = UploadStatusDto::from(status); + let json = serde_json::to_string(&dto).unwrap(); + let back: UploadStatus = + serde_json::from_str::(&json) + .unwrap() + .into(); + assert_eq!(&back, status); + } + } + + #[test] + fn every_room_event_survives_the_round_trip() { + let events = [ + RoomEvent::List(vec![RoomInfo { + name: "lobby".into(), + user_count: 42, + }]), + RoomEvent::Joined { + room: "lobby".into(), + users: vec!["bob".into()], + }, + RoomEvent::Left { + room: "lobby".into(), + }, + RoomEvent::Message { + room: "lobby".into(), + username: "bob".into(), + message: "hi".into(), + }, + RoomEvent::UserJoined { + room: "lobby".into(), + username: "sue".into(), + }, + RoomEvent::UserLeft { + room: "lobby".into(), + username: "sue".into(), + }, + ]; + for event in &events { + let json = + serde_json::to_string(&RoomEventDto::from(event)).unwrap(); + let back: RoomEvent = + serde_json::from_str::(&json).unwrap().into(); + assert_eq!(&back, event); + } + } + + #[test] + fn a_partly_answered_user_stays_partly_answered() { + // "the server has not said" must not arrive as "offline, shares + // nothing": that is the difference between waiting and giving up. + let pending = UserInfo::pending("bob".into()); + let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap(); + let back: UserInfo = + serde_json::from_str::(&json).unwrap().into(); + assert_eq!(back.username, "bob"); + assert!(back.presence.is_none()); + assert!(back.stats.is_none()); + assert!(!back.is_complete()); + } + + #[test] + fn a_complete_user_survives_the_round_trip() { + let mut info = UserInfo::pending("bob".into()); + info.presence = Some(UserPresence { + status: UserStatus::Away, + privileged: true, + }); + info.stats = Some(UserStats { + average_speed: 500, + shared_files: 10, + shared_folders: 2, + }); + + let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap(); + let back: UserInfo = + serde_json::from_str::(&json).unwrap().into(); + assert_eq!(back, info); + } + + #[test] + fn a_private_message_survives_the_round_trip() { + let message = + UserMessage::new(9, 1234, "bob".into(), "hi".into(), true); + let json = + serde_json::to_string(&UserMessageDto::from(&message)).unwrap(); + let back: UserMessage = serde_json::from_str::(&json) + .unwrap() + .into(); + assert_eq!(back.id(), 9); + assert_eq!(back.timestamp(), 1234); + assert_eq!(back.username(), "bob"); + assert_eq!(back.message(), "hi"); + } + + #[test] + fn a_shared_listing_survives_the_round_trip() { + let directory = SharedDirectory { + name: "@@x\\Music".into(), + files: vec![("a.mp3".into(), 4096)], + }; + let json = serde_json::to_string(&SharedDirectoryDto::from(&directory)) + .unwrap(); + let back: SharedDirectory = + serde_json::from_str::(&json) + .unwrap() + .into(); + assert_eq!(back.name, directory.name); + assert_eq!(back.files, directory.files); + } + + #[test] + fn an_error_carries_the_exit_code_a_local_run_would_have_produced() { + for exit in [ + Exit::Usage, + Exit::Connection, + Exit::NoResults, + Exit::Timeout, + Exit::Transfer, + Exit::SessionLost, + ] { + let error = RpcError::application(exit, "nope"); + let json = serde_json::to_string(&error).unwrap(); + let back: RpcError = serde_json::from_str(&json).unwrap(); + assert_eq!(back.exit(), exit); + } + } + + #[test] + fn an_error_without_an_exit_code_is_a_generic_failure() { + let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method"); + assert_eq!(error.exit(), Exit::Failure); + } + + #[test] + fn a_request_parses_without_optional_fields() { + let request: Request = serde_json::from_str( + r#"{"jsonrpc":"2.0","method":"daemon.status"}"#, + ) + .expect("params and id are optional"); + assert_eq!(request.method, "daemon.status"); + assert!(request.id.is_none()); + assert!(request.params.is_null()); + } + + #[test] + fn an_error_always_carries_an_id_even_when_there_was_none() { + // JSON-RPC requires it, and without it a client following the + // documented "no id means a notification" rule drops the error and + // waits out its timeout for a reply that already arrived. + let json = serde_json::to_string(&Response::failure( + None, + RpcError::new(CODE_PARSE, "unparseable"), + )) + .expect("a response is plain data"); + assert!(json.contains("\"id\":null"), "{json}"); + } + + #[test] + fn a_notification_carries_no_id_so_no_reply_is_expected() { + let notification = + Response::notification("event.room", serde_json::json!({"a": 1})); + let json = serde_json::to_string(¬ification).unwrap(); + assert!(!json.contains("\"id\""), "{json}"); + assert!(json.contains("\"method\":\"event.room\""), "{json}"); + } + + #[test] + fn a_response_serializes_exactly_one_of_result_and_error() { + let ok = serde_json::to_string(&Response::result( + Some(serde_json::json!(1)), + serde_json::json!({}), + )) + .unwrap(); + assert!(ok.contains("\"result\"")); + assert!(!ok.contains("\"error\"")); + + let failed = serde_json::to_string(&Response::failure( + Some(serde_json::json!(1)), + RpcError::application(Exit::NoResults, "nothing"), + )) + .unwrap(); + assert!(failed.contains("\"error\"")); + assert!(!failed.contains("\"result\"")); + } +} diff --git a/soulseek-rs/src/daemon/server.rs b/soulseek-rs/src/daemon/server/mod.rs similarity index 59% rename from soulseek-rs/src/daemon/server.rs rename to soulseek-rs/src/daemon/server/mod.rs index 5f561f3..18e11e3 100644 --- a/soulseek-rs/src/daemon/server.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -7,12 +7,10 @@ use super::hub::{Hub, PendingBrowses, PendingDownload}; use super::proto::{ - Ack, AuthParams, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, - ChatMessageDto, - CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE, DaemonStatus, - DirectoriesParams, DownloadDto, DownloadStartParams, DownloadStarted, - Downloads, IntervalSeconds, MessageParams, Members, Messages, Method, - OPENRPC, PROTOCOL_VERSION, QueryParams, Request, Response, RoomRef, + Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto, + DaemonStatus, DirectoriesParams, DownloadDto, DownloadStartParams, + DownloadStarted, Downloads, IntervalSeconds, Members, MessageParams, + Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams, RoomRef, RpcError, SayParams, SearchResultDto, SearchResults, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult, @@ -21,21 +19,10 @@ use crate::api::SessionApi; use crate::output::Exit; use serde::de::DeserializeOwned; use serde_json::Value; -use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// How long a failed authentication costs the caller. Not a serious defence — -/// the token is 256 bits — but it keeps a wrong-token loop off the CPU. -const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1); - -/// Replies waiting to go out on one connection. Bounded, so a client that has -/// stopped reading cannot make the daemon buffer without limit. -const WRITE_QUEUE_DEPTH: usize = 1024; - /// Everything a connection needs to answer a request. pub struct Daemon { pub session: Arc, @@ -49,6 +36,9 @@ pub struct Daemon { pub token: String, pub started: Instant, pub stop: Arc, + /// Connections currently being served, capped so an unauthenticated peer + /// cannot spawn threads without bound. + pub open: Arc, /// Rooms this session has joined, so a restart can rejoin them. pub rooms: Mutex>, } @@ -59,17 +49,27 @@ impl Daemon { /// The match is exhaustive over [`Method`], so a new method cannot be added /// to the protocol without being implemented here. #[allow(clippy::too_many_lines)] - pub fn call(&self, method: Method, params: Value) -> Result { + pub fn call( + &self, + method: Method, + params: Value, + ) -> Result { match method { - // Handled during the handshake; reaching here means a second auth. - Method::Auth => ok(Ack::OK), + // Handled during the handshake; reaching here means a second + // `auth` on an established connection, which is answered with the + // shape the contract publishes rather than a bare acknowledgement. + Method::Auth => ok(self.identify()), Method::DaemonStatus => ok(self.status()), Method::DaemonStop => { self.stop.store(true, Ordering::Relaxed); ok(Ack::OK) } - Method::RpcDiscover => serde_json::from_str(OPENRPC) - .map_err(|e| internal(format!("the contract is unreadable: {e}"))), + Method::RpcDiscover => serde_json::from_str(OPENRPC).map_err(|e| { + RpcError::new( + CODE_APPLICATION, + format!("the contract is unreadable: {e}"), + ) + }), Method::SearchStart => { let query: QueryParams = parse(params)?; @@ -78,7 +78,12 @@ impl Daemon { // what keeps one client's search from blocking the daemon. self.session .search_with_cancel(&query.query, Duration::ZERO, None) - .map_err(|e| failed(Exit::Connection, format!("search failed: {e}")))?; + .map_err(|e| { + RpcError::application( + Exit::Connection, + format!("search failed: {e}"), + ) + })?; ok(Ack::OK) } Method::SearchResults => { @@ -94,9 +99,9 @@ impl Daemon { } Method::SearchWishlist => { let query: QueryParams = parse(params)?; - self.session - .start_wishlist_search(&query.query) - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.start_wishlist_search(&query.query).map_err( + |e| RpcError::application(Exit::Connection, e.to_string()), + )?; ok(Ack::OK) } Method::SearchWishlistInterval => ok(IntervalSeconds { @@ -123,17 +128,19 @@ impl Daemon { Method::DownloadResume => { let transfer: TransferRef = parse(params)?; ok(Ack { - ok: self - .session - .resume_download(&transfer.username, &transfer.filename), + ok: self.session.resume_download( + &transfer.username, + &transfer.filename, + ), }) } Method::DownloadRemove => { let transfer: TransferRef = parse(params)?; ok(Ack { - ok: self - .session - .remove_download(&transfer.username, &transfer.filename), + ok: self.session.remove_download( + &transfer.username, + &transfer.filename, + ), }) } Method::DownloadRemoveQueued => { @@ -169,9 +176,9 @@ impl Daemon { } Method::PrivilegesCheck => { - self.session - .check_privileges() - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.check_privileges().map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; ok(Ack::OK) } Method::PrivilegesOwn => ok(Seconds { @@ -179,32 +186,32 @@ impl Daemon { }), Method::RoomListRequest => { - self.session - .request_room_list() - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.request_room_list().map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; ok(Ack::OK) } Method::RoomJoin => { let room: RoomRef = parse(params)?; - self.session - .join_room(&room.room) - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.join_room(&room.room).map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; self.joined(&room.room, true); ok(Ack::OK) } Method::RoomLeave => { let room: RoomRef = parse(params)?; - self.session - .leave_room(&room.room) - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.leave_room(&room.room).map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; self.joined(&room.room, false); ok(Ack::OK) } Method::RoomSay => { let say: SayParams = parse(params)?; - self.session - .say_in_room(&say.room, &say.message) - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.say_in_room(&say.room, &say.message).map_err( + |e| RpcError::application(Exit::Connection, e.to_string()), + )?; ok(Ack::OK) } Method::RoomMembers => { @@ -218,7 +225,9 @@ impl Daemon { let message: MessageParams = parse(params)?; self.session .send_private_message(&message.username, &message.message) - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + .map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; // Record our own half of the conversation: the drainer only // ever sees incoming messages, so without this a client that // attaches later reads a one-sided chat. @@ -241,15 +250,18 @@ impl Daemon { self.browses.expect(&user.username); if let Err(e) = self.session.browse_user(&user.username) { self.browses.forget(&user.username); - return Err(failed(Exit::Connection, e.to_string())); + return Err(RpcError::application( + Exit::Connection, + e.to_string(), + )); } ok(Ack::OK) } Method::UserRequest => { let user: UserRef = parse(params)?; - self.session - .request_user_info(&user.username) - .map_err(|e| failed(Exit::Connection, e.to_string()))?; + self.session.request_user_info(&user.username).map_err( + |e| RpcError::application(Exit::Connection, e.to_string()), + )?; ok(Ack::OK) } Method::UserInfoOf => { @@ -268,13 +280,17 @@ impl Daemon { let directories: DirectoriesParams = parse(params)?; self.session .set_shared_directories(directories.directories) - .map_err(|e| failed(Exit::Usage, e.to_string()))?; + .map_err(|e| { + RpcError::application(Exit::Usage, e.to_string()) + })?; ok(self.shares()) } Method::SharesReindex => { self.session .set_shared_directories(self.session.shared_directories()) - .map_err(|e| failed(Exit::Usage, e.to_string()))?; + .map_err(|e| { + RpcError::application(Exit::Usage, e.to_string()) + })?; ok(self.shares()) } } @@ -303,9 +319,11 @@ impl Daemon { &self, params: DownloadStartParams, ) -> Result { - let directory = params - .download_directory - .unwrap_or_else(|| self.download_dir.clone()); + // The daemon's own directory, always. Letting a caller name a path + // would hand any authenticated client an arbitrary write on this + // host's filesystem, and a remote one cannot see that filesystem to + // choose sensibly anyway. + let directory = self.download_dir.clone(); let (download, updates) = self .session .download_with_metadata( @@ -315,7 +333,12 @@ impl Daemon { directory, params.metadata.into(), ) - .map_err(|e| failed(Exit::Transfer, format!("cannot start: {e}")))?; + .map_err(|e| { + RpcError::application( + Exit::Transfer, + format!("cannot start: {e}"), + ) + })?; if let Ok(mut pending) = self.downloads.lock() { pending.push(PendingDownload { @@ -338,6 +361,14 @@ impl Daemon { } } + fn identify(&self) -> AuthResult { + AuthResult { + protocol: PROTOCOL_VERSION, + daemon_version: env!("CARGO_PKG_VERSION").to_string(), + username: self.session.username(), + } + } + fn status(&self) -> DaemonStatus { let (folders, files) = self.session.shared_counts(); DaemonStatus { @@ -357,8 +388,12 @@ impl Daemon { } fn ok(value: T) -> Result { - serde_json::to_value(value) - .map_err(|e| internal(format!("cannot serialize the reply: {e}"))) + serde_json::to_value(value).map_err(|e| { + RpcError::new( + CODE_APPLICATION, + format!("cannot serialize the reply: {e}"), + ) + }) } fn parse(params: Value) -> Result { @@ -366,14 +401,6 @@ fn parse(params: Value) -> Result { .map_err(|e| RpcError::new(CODE_INVALID_PARAMS, e.to_string())) } -fn failed(exit: Exit, message: impl Into) -> RpcError { - RpcError::application(exit, message) -} - -fn internal(message: impl Into) -> RpcError { - RpcError::new(CODE_APPLICATION, message) -} - /// Wall-clock seconds, to stamp a message the way the server stamps the ones /// it delivers. A clock before the epoch reads as zero rather than panicking. fn now_seconds() -> i64 { @@ -382,336 +409,27 @@ fn now_seconds() -> i64 { .map_or(0, |since| since.as_secs() as i64) } -// --------------------------------------------------------------------------- -// Transports -// --------------------------------------------------------------------------- +mod transport; -/// Where control connections arrive. -/// -/// The Unix socket is the local path — its file permissions are the -/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in -/// and always needs the token. -pub enum Listener { - #[cfg(unix)] - Unix(std::os::unix::net::UnixListener), - Tcp(TcpListener), -} - -pub enum Stream { - #[cfg(unix)] - Unix(std::os::unix::net::UnixStream), - Tcp(TcpStream), -} - -impl Stream { - fn try_clone(&self) -> std::io::Result { - match self { - #[cfg(unix)] - Self::Unix(stream) => stream.try_clone().map(Self::Unix), - Self::Tcp(stream) => stream.try_clone().map(Self::Tcp), - } - } - - /// Whether the transport itself proves who is calling. - const fn trusted_by_transport(&self) -> bool { - match self { - #[cfg(unix)] - Self::Unix(_) => true, - Self::Tcp(_) => false, - } - } -} - -impl std::io::Read for Stream { - fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { - match self { - #[cfg(unix)] - Self::Unix(stream) => stream.read(buffer), - Self::Tcp(stream) => stream.read(buffer), - } - } -} - -impl Write for Stream { - fn write(&mut self, buffer: &[u8]) -> std::io::Result { - match self { - #[cfg(unix)] - Self::Unix(stream) => stream.write(buffer), - Self::Tcp(stream) => stream.write(buffer), - } - } - - fn flush(&mut self) -> std::io::Result<()> { - match self { - #[cfg(unix)] - Self::Unix(stream) => stream.flush(), - Self::Tcp(stream) => stream.flush(), - } - } -} - -impl Listener { - /// Accept forever, handing each connection to its own thread. - pub fn serve(self, daemon: Arc) { - match self { - #[cfg(unix)] - Self::Unix(listener) => { - for stream in listener.incoming() { - match stream { - Ok(stream) => spawn_connection( - Stream::Unix(stream), - Arc::clone(&daemon), - ), - Err(e) => { - soulseek_rs::warn!("control accept failed: {e}"); - } - } - } - } - Self::Tcp(listener) => { - for stream in listener.incoming() { - match stream { - Ok(stream) => spawn_connection( - Stream::Tcp(stream), - Arc::clone(&daemon), - ), - Err(e) => { - soulseek_rs::warn!("control accept failed: {e}"); - } - } - } - } - } - } -} - -fn spawn_connection(stream: Stream, daemon: Arc) { - std::thread::spawn(move || { - if let Err(e) = handle(stream, daemon) { - soulseek_rs::debug!("control connection ended: {e}"); - } - }); -} - -/// Serve one connection: authenticate, then answer requests until it closes. -fn handle(stream: Stream, daemon: Arc) -> std::io::Result<()> { - let reader = BufReader::new(stream.try_clone()?); - let trusted = stream.trusted_by_transport(); - - let (out, outbox) = sync_channel::(WRITE_QUEUE_DEPTH); - let writer = std::thread::spawn(move || write_loop(stream, &outbox)); - - let mut lines = reader.lines(); - let authenticated = authenticate(&mut lines, &daemon, trusted, &out); - if !authenticated { - drop(out); - let _ = writer.join(); - return Ok(()); - } - - // Only now does this connection get to see events: an unauthenticated - // socket must learn nothing at all. - let (subscription, events) = daemon.hub.subscribe(); - let pump = { - let out = out.clone(); - std::thread::spawn(move || { - for event in events { - if out.send(event).is_err() { - break; - } - } - }) - }; - - // Requests are answered on this thread, in order. A method slow enough to - // matter (re-indexing a large share) delays only the connection that asked - // for it, which is the right person to wait — and every other client has a - // connection of its own. Spawning per request would instead churn a thread - // for each of the handful of polls a TUI makes every frame. - for line in lines { - let line = line?; - if line.trim().is_empty() { - continue; - } - if let Some(response) = answer(&line, &daemon) - && out.send(response).is_err() - { - break; - } - } - - daemon.hub.unsubscribe(subscription); - drop(out); - let _ = pump.join(); - let _ = writer.join(); - Ok(()) -} - -/// The first request has to be `auth`, and has to be good. -fn authenticate( - lines: &mut std::io::Lines>, - daemon: &Daemon, - trusted: bool, - out: &SyncSender, -) -> bool { - let Some(Ok(line)) = lines.next() else { - return false; - }; - let request: Request = match serde_json::from_str(&line) { - Ok(request) => request, - Err(e) => { - let _ = out.send(Response::failure( - None, - RpcError::new(CODE_PARSE, e.to_string()), - )); - return false; - } - }; - - if Method::from_wire(&request.method) != Some(Method::Auth) { - let _ = out.send(Response::failure( - request.id, - RpcError::new( - CODE_INVALID_REQUEST, - "the first request on a connection must be `auth`", - ), - )); - return false; - } - - let params: AuthParams = - serde_json::from_value(request.params).unwrap_or_default(); - if !accepts(daemon, trusted, params.token.as_deref()) { - std::thread::sleep(AUTH_FAILURE_DELAY); - let _ = out.send(Response::failure( - request.id, - RpcError::application(Exit::Connection, "authentication failed"), - )); - return false; - } - - let result = serde_json::to_value(AuthResult { - protocol: PROTOCOL_VERSION, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), - username: daemon.session.username(), - }) - .unwrap_or(Value::Null); - let _ = out.send(Response::result(request.id, result)); - true -} - -/// A Unix socket has already proved the caller is this user, so a token is -/// optional there and mandatory everywhere else. A token that *is* offered is -/// always checked, so a wrong one never passes. -fn accepts(daemon: &Daemon, trusted: bool, token: Option<&str>) -> bool { - match token { - Some(token) => constant_time_eq(token, &daemon.token), - None => trusted, - } -} - -/// Compare without leaking the position of the first difference through timing. -fn constant_time_eq(left: &str, right: &str) -> bool { - if left.len() != right.len() { - return false; - } - left.bytes() - .zip(right.bytes()) - .fold(0u8, |difference, (a, b)| difference | (a ^ b)) - == 0 -} - -/// Turn one request line into the reply it deserves, or `None` for a -/// notification, which by JSON-RPC rule is answered with silence. -fn answer(line: &str, daemon: &Daemon) -> Option { - let request: Request = match serde_json::from_str(line) { - Ok(request) => request, - Err(e) => { - return Some(Response::failure( - None, - RpcError::new(CODE_PARSE, e.to_string()), - )); - } - }; - let id = request.id.clone(); - let Some(method) = Method::from_wire(&request.method) else { - return id.is_some().then(|| { - Response::failure( - request.id, - RpcError::new( - CODE_METHOD_NOT_FOUND, - format!("no such method: {}", request.method), - ), - ) - }); - }; - - let outcome = daemon.call(method, request.params); - id.map(|id| match outcome { - Ok(result) => Response::result(Some(id), result), - Err(error) => Response::failure(Some(id), error), - }) -} - -fn write_loop(stream: Stream, outbox: &Receiver) { - let mut stream = stream; - for response in outbox { - let Ok(line) = serde_json::to_string(&response) else { - continue; - }; - if writeln!(stream, "{line}").is_err() || stream.flush().is_err() { - return; - } - } -} - -/// Bind the TCP control port, refusing to come up rather than silently -/// listening somewhere unintended. -pub fn bind_tcp(address: &str) -> std::io::Result { - TcpListener::bind(address).map(Listener::Tcp) -} - -/// Bind the Unix control socket with permissions that make it the local -/// authentication: only this user can open it. #[cfg(unix)] -pub fn bind_unix(path: &std::path::Path) -> std::io::Result { - use std::os::unix::fs::PermissionsExt; - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - // A socket file outlives the process that made it, so a previous crash - // would otherwise make the daemon unstartable. - if path.exists() { - remove_stale_socket(path)?; - } - let listener = std::os::unix::net::UnixListener::bind(path)?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - Ok(Listener::Unix(listener)) -} - -/// Remove a socket file left by a dead daemon — but never one a live daemon is -/// still serving, which would silently steal its clients. -#[cfg(unix)] -fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> { - match std::os::unix::net::UnixStream::connect(path) { - Ok(_) => Err(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - format!("a daemon is already listening on {}", path.display()), - )), - Err(_) => std::fs::remove_file(path), - } -} +pub use transport::bind_unix; +pub use transport::{Listener, bind_tcp}; #[cfg(test)] mod tests { + use super::super::proto::{CODE_METHOD_NOT_FOUND, CODE_PARSE}; + use super::transport::{accepts, answer, constant_time_eq}; use super::*; use serde_json::json; + use std::sync::mpsc::Receiver; #[test] fn a_unix_socket_needs_no_token_but_tcp_does() { let daemon = daemon_with_token("secret"); - assert!(accepts(&daemon, true, None), "socket permissions are the auth"); + assert!( + accepts(&daemon, true, None), + "socket permissions are the auth" + ); assert!(!accepts(&daemon, false, None), "TCP must demand a token"); } @@ -784,8 +502,9 @@ mod tests { assert!( document["methods"] .as_array() - .is_some_and(|methods| methods.len() == Method::ALL.len() - + super::super::proto::Event::ALL.len()), + .is_some_and(|methods| methods.len() + == Method::ALL.len() + + super::super::proto::Event::ALL.len()), "discover must describe every method and event" ); } @@ -815,6 +534,7 @@ mod tests { token: token.to_string(), started: Instant::now(), stop: Arc::new(AtomicBool::new(false)), + open: Arc::new(std::sync::atomic::AtomicUsize::new(0)), rooms: Mutex::new(Vec::new()), } } @@ -859,7 +579,10 @@ mod tests { ) -> Option> { None } - fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> { + fn start_wishlist_search( + &self, + _query: &str, + ) -> soulseek_rs::Result<()> { Ok(()) } fn wishlist_interval(&self) -> Duration { @@ -972,7 +695,10 @@ mod tests { ) -> Option> { None } - fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { + fn request_user_info( + &self, + _username: &str, + ) -> soulseek_rs::Result<()> { Ok(()) } fn user_info(&self, _username: &str) -> Option { diff --git a/soulseek-rs/src/daemon/server/transport.rs b/soulseek-rs/src/daemon/server/transport.rs new file mode 100644 index 0000000..649ffb8 --- /dev/null +++ b/soulseek-rs/src/daemon/server/transport.rs @@ -0,0 +1,429 @@ +//! Getting bytes to and from a control client: who may connect, how a request +//! is framed, and how replies and pushed events share one socket. +//! +//! One thread per connection, which is noise next to the thousand-odd peer +//! threads the client already runs. Each connection owns a writer thread fed by +//! a channel, so request replies and events interleave without a lock and +//! neither can block the other. + +use super::super::proto::{ + AuthParams, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE, + Method, Request, Response, RpcError, +}; +use super::Daemon; +use crate::output::Exit; +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::time::Duration; + +/// How long a failed authentication costs the caller. Not a serious defence — +/// the token is 256 bits — but it keeps a wrong-token loop off the CPU. +const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1); + +/// Replies waiting to go out on one connection. Bounded, so a client that has +/// stopped reading cannot make the daemon buffer without limit. +const WRITE_QUEUE_DEPTH: usize = 1024; + +/// The longest request line the daemon will read. Generous next to the largest +/// real request (a `shares.set` naming every folder) and small enough that an +/// unauthenticated peer cannot make the daemon allocate without bound. +const MAX_LINE: u64 = 1 << 20; + +/// How long an unauthenticated connection may stay silent. Applies only until +/// `auth` succeeds — an established client is expected to idle, holding its +/// connection open for events without sending anything. +const AUTH_TIMEOUT: Duration = Duration::from_secs(30); + +/// How many control connections may be open at once. Far above any real use +/// (a TUI, a few scripts, an agent), and low enough that an unauthenticated +/// peer cannot exhaust the daemon's threads. +const MAX_CONNECTIONS: usize = 64; + +/// Where control connections arrive. +/// +/// The Unix socket is the local path — its file permissions are the +/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in +/// and always needs the token. +pub enum Listener { + #[cfg(unix)] + Unix(std::os::unix::net::UnixListener), + Tcp(TcpListener), +} + +pub enum Stream { + #[cfg(unix)] + Unix(std::os::unix::net::UnixStream), + Tcp(TcpStream), +} + +impl Stream { + fn try_clone(&self) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.try_clone().map(Self::Unix), + Self::Tcp(stream) => stream.try_clone().map(Self::Tcp), + } + } + + fn set_read_timeout(&self, timeout: Option) { + let _ = match self { + #[cfg(unix)] + Self::Unix(stream) => stream.set_read_timeout(timeout), + Self::Tcp(stream) => stream.set_read_timeout(timeout), + }; + } + + /// End the connection from another thread. This is what makes "a client + /// that stops reading is disconnected" true rather than aspirational: the + /// hub can only reach a stalled client by closing its socket. + pub(super) fn shutdown(&self) { + let _ = match self { + #[cfg(unix)] + Self::Unix(stream) => stream.shutdown(std::net::Shutdown::Both), + Self::Tcp(stream) => stream.shutdown(std::net::Shutdown::Both), + }; + } + + /// Whether the transport itself proves who is calling. + const fn trusted_by_transport(&self) -> bool { + match self { + #[cfg(unix)] + Self::Unix(_) => true, + Self::Tcp(_) => false, + } + } +} + +impl std::io::Read for Stream { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.read(buffer), + Self::Tcp(stream) => stream.read(buffer), + } + } +} + +impl Write for Stream { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.write(buffer), + Self::Tcp(stream) => stream.write(buffer), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Self::Unix(stream) => stream.flush(), + Self::Tcp(stream) => stream.flush(), + } + } +} + +impl Listener { + /// Accept forever, handing each connection to its own thread. + pub fn serve(self, daemon: Arc) { + loop { + match self.accept() { + Ok(stream) => spawn_connection(stream, Arc::clone(&daemon)), + Err(e) => soulseek_rs::warn!("control accept failed: {e}"), + } + } + } + + fn accept(&self) -> std::io::Result { + match self { + #[cfg(unix)] + Self::Unix(listener) => { + listener.accept().map(|(stream, _)| Stream::Unix(stream)) + } + Self::Tcp(listener) => { + listener.accept().map(|(stream, _)| Stream::Tcp(stream)) + } + } + } +} + +fn spawn_connection(stream: Stream, daemon: Arc) { + let open = daemon.open.fetch_add(1, Ordering::Relaxed) + 1; + if open > MAX_CONNECTIONS { + daemon.open.fetch_sub(1, Ordering::Relaxed); + soulseek_rs::warn!( + "refusing a control connection: {MAX_CONNECTIONS} already open" + ); + stream.shutdown(); + return; + } + std::thread::spawn(move || { + if let Err(e) = handle(stream, daemon.as_ref()) { + soulseek_rs::debug!("control connection ended: {e}"); + } + daemon.open.fetch_sub(1, Ordering::Relaxed); + }); +} + +/// Serve one connection: authenticate, then answer requests until it closes. +fn handle(stream: Stream, daemon: &Daemon) -> std::io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let trusted = stream.trusted_by_transport(); + let closer = stream.try_clone()?; + + // A peer that connects and says nothing must not hold a thread forever. + stream.set_read_timeout(Some(AUTH_TIMEOUT)); + + let (out, outbox) = sync_channel::(WRITE_QUEUE_DEPTH); + let writer = std::thread::spawn(move || write_loop(stream, &outbox)); + + if !authenticate(&mut reader, daemon, trusted, &out) { + closer.shutdown(); + drop(out); + let _ = writer.join(); + return Ok(()); + } + // An established client is expected to idle, holding the connection open + // for events without sending anything. + closer.set_read_timeout(None); + + // Only now does this connection get to see events: an unauthenticated + // socket must learn nothing at all. + let (subscription, events) = { + let closer = closer.try_clone()?; + daemon.hub.subscribe(move || closer.shutdown()) + }; + let pump = { + let out = out.clone(); + std::thread::spawn(move || { + for event in events { + if out.send(event).is_err() { + break; + } + } + }) + }; + + // Requests are answered on this thread, in order. A method slow enough to + // matter (re-indexing a large share) delays only the connection that asked + // for it, which is the right person to wait — and every other client has a + // connection of its own. Spawning per request would instead churn a thread + // for each of the handful of polls a TUI makes every frame. + // + // Errors break rather than propagate: returning here would skip the + // unsubscribe below and leak a hub subscriber plus two threads for the + // life of the daemon. + loop { + match read_line(&mut reader) { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + if let Some(response) = answer(&line, daemon) + && out.send(response).is_err() + { + break; + } + } + Ok(None) => break, + Err(e) => { + soulseek_rs::debug!("control connection ended: {e}"); + break; + } + } + } + + daemon.hub.unsubscribe(subscription); + closer.shutdown(); + drop(out); + let _ = pump.join(); + let _ = writer.join(); + Ok(()) +} + +/// Read one newline-delimited request, refusing a line long enough to be an +/// attack rather than a request. `Ok(None)` means the peer hung up. +fn read_line( + reader: &mut BufReader, +) -> std::io::Result> { + let mut line = String::new(); + let read = reader.by_ref().take(MAX_LINE).read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + if read as u64 == MAX_LINE && !line.ends_with('\n') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request line exceeds the maximum size", + )); + } + Ok(Some(line)) +} + +/// The first request has to be `auth`, and has to be good. +fn authenticate( + reader: &mut BufReader, + daemon: &Daemon, + trusted: bool, + out: &SyncSender, +) -> bool { + let Ok(Some(line)) = read_line(reader) else { + return false; + }; + let request: Request = match serde_json::from_str(&line) { + Ok(request) => request, + Err(e) => { + let _ = out.send(Response::failure( + None, + RpcError::new(CODE_PARSE, e.to_string()), + )); + return false; + } + }; + + if Method::from_wire(&request.method) != Some(Method::Auth) { + let _ = out.send(Response::failure( + request.id, + RpcError::new( + CODE_INVALID_REQUEST, + "the first request on a connection must be `auth`", + ), + )); + return false; + } + + let params: AuthParams = + serde_json::from_value(request.params).unwrap_or_default(); + if !accepts(daemon, trusted, params.token.as_deref()) { + std::thread::sleep(AUTH_FAILURE_DELAY); + let _ = out.send(Response::failure( + request.id, + RpcError::application(Exit::Connection, "authentication failed"), + )); + return false; + } + + let result = serde_json::to_value(daemon.identify()).unwrap_or(Value::Null); + let _ = out.send(Response::result(request.id, result)); + true +} + +/// A Unix socket has already proved the caller is this user, so a token is +/// optional there and mandatory everywhere else. A token that *is* offered is +/// always checked, so a wrong one never passes. +pub(super) fn accepts( + daemon: &Daemon, + trusted: bool, + token: Option<&str>, +) -> bool { + match token { + Some(token) => constant_time_eq(token, &daemon.token), + None => trusted, + } +} + +/// Compare without leaking the position of the first difference through timing. +pub(super) fn constant_time_eq(left: &str, right: &str) -> bool { + if left.len() != right.len() { + return false; + } + left.bytes() + .zip(right.bytes()) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +/// Turn one request line into the reply it deserves, or `None` for a +/// notification, which by JSON-RPC rule is answered with silence. +pub(super) fn answer(line: &str, daemon: &Daemon) -> Option { + let request: Request = match serde_json::from_str(line) { + Ok(request) => request, + Err(e) => { + return Some(Response::failure( + None, + RpcError::new(CODE_PARSE, e.to_string()), + )); + } + }; + let id = request.id.clone(); + let Some(method) = Method::from_wire(&request.method) else { + return id.is_some().then(|| { + Response::failure( + request.id, + RpcError::new( + CODE_METHOD_NOT_FOUND, + format!("no such method: {}", request.method), + ), + ) + }); + }; + + let outcome = daemon.call(method, request.params); + id.map(|id| match outcome { + Ok(result) => Response::result(Some(id), result), + Err(error) => Response::failure(Some(id), error), + }) +} + +fn write_loop(stream: Stream, outbox: &Receiver) { + let mut stream = stream; + for response in outbox { + let Ok(line) = serde_json::to_string(&response) else { + continue; + }; + if writeln!(stream, "{line}").is_err() || stream.flush().is_err() { + return; + } + } +} + +/// Bind the TCP control port, refusing to come up rather than silently +/// listening somewhere unintended. +pub fn bind_tcp(address: &str) -> std::io::Result { + TcpListener::bind(address).map(Listener::Tcp) +} + +/// Bind the Unix control socket with permissions that make it the local +/// authentication: only this user can open it. +/// +/// Bound under a temporary name and moved into place, because `bind` creates +/// the socket with the process umask — for the moment between `bind` and +/// `set_permissions` the real path would be connectable by anyone on the +/// machine, and on this transport connecting *is* authenticating. The rename +/// is atomic, so the published path never exists with the wrong mode. +#[cfg(unix)] +pub fn bind_unix(path: &std::path::Path) -> std::io::Result { + use std::os::unix::fs::PermissionsExt; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + // A socket file outlives the process that made it, so a previous crash + // would otherwise make the daemon unstartable. + if path.exists() { + remove_stale_socket(path)?; + } + + let staging = path.with_extension("binding"); + let _ = std::fs::remove_file(&staging); + let listener = std::os::unix::net::UnixListener::bind(&staging)?; + std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o600))?; + std::fs::rename(&staging, path)?; + Ok(Listener::Unix(listener)) +} + +/// Remove a socket file left by a dead daemon — but never one a live daemon is +/// still serving, which would silently steal its clients. +#[cfg(unix)] +fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> { + match std::os::unix::net::UnixStream::connect(path) { + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + format!("a daemon is already listening on {}", path.display()), + )), + Err(_) => std::fs::remove_file(path), + } +} diff --git a/soulseek-rs/src/remote/mirror.rs b/soulseek-rs/src/remote/mirror.rs index f5327d6..1f995f9 100644 --- a/soulseek-rs/src/remote/mirror.rs +++ b/soulseek-rs/src/remote/mirror.rs @@ -18,6 +18,14 @@ use std::collections::HashMap; use std::sync::Mutex; use std::sync::mpsc::Sender; +/// How many undrained events of one kind a client may accumulate. +/// +/// Every attached client is sent everything, including events for work another +/// client asked for. A command that only drains room events would otherwise +/// grow its private-message buffer for as long as it runs. Dropping the oldest +/// is right for a buffer nobody is reading: the newest state is the useful one. +const BUFFER_LIMIT: usize = 4096; + /// Everything a pushed event can land in, behind one lock. /// /// One lock rather than several: it is taken for the length of a `Vec::push` @@ -49,21 +57,24 @@ impl Mirror { match event { Event::Room => { if let Ok(room) = parse::(event, params) { - buffers.rooms.push(room.into()); + push(&mut buffers.rooms, room.into()); } } Event::Message => { if let Ok(message) = parse::(event, params) { - buffers.messages.push(message.into()); + push(&mut buffers.messages, message.into()); } } Event::Upload => { if let Ok(upload) = parse::(event, params) { - buffers.uploads.push(upload.into()); + push(&mut buffers.uploads, upload.into()); } } Event::Browse => { if let Ok(browse) = parse::(event, params) { + if buffers.browses.len() >= BUFFER_LIMIT { + buffers.browses.clear(); + } buffers.browses.insert( browse.username, browse @@ -112,6 +123,18 @@ impl Mirror { } } + /// Stop tracking every transfer, so anything blocked on one is told the + /// connection is gone. + /// + /// Dropping the senders is the signal: a caller waiting on its receiver + /// sees a disconnect immediately instead of sitting out its full transfer + /// timeout and then reporting a misleading "gave up". + pub fn abandon_downloads(&self) { + if let Ok(mut buffers) = self.0.lock() { + buffers.downloads.clear(); + } + } + /// Register where a transfer's updates should go before asking for it, so /// an update that lands immediately is not dropped on the floor. pub fn expect_download( @@ -156,10 +179,20 @@ impl Mirror { } fn take(&self, drain: impl FnOnce(&mut Buffers) -> T) -> T { - self.0.lock().map_or_else(|_| T::default(), |mut b| drain(&mut b)) + self.0 + .lock() + .map_or_else(|_| T::default(), |mut b| drain(&mut b)) } } +/// Append, dropping the oldest once the buffer is at its limit. +fn push(buffer: &mut Vec, item: T) { + if buffer.len() >= BUFFER_LIMIT { + buffer.remove(0); + } + buffer.push(item); +} + fn parse( event: Event, params: serde_json::Value, @@ -178,10 +211,7 @@ mod tests { #[test] fn a_room_event_is_delivered_once_and_then_gone() { let mirror = Mirror::default(); - mirror.apply( - Event::Room, - json!({ "event": "left", "room": "lobby" }), - ); + mirror.apply(Event::Room, json!({ "event": "left", "room": "lobby" })); assert_eq!( mirror.take_rooms(), @@ -286,6 +316,35 @@ mod tests { ); } + #[test] + fn an_undrained_buffer_stops_growing_at_its_limit() { + // A client is sent every event, including ones for work it never asked + // for; a command that drains only rooms must not grow without bound. + let mirror = Mirror::default(); + for _ in 0..(BUFFER_LIMIT + 50) { + mirror.apply( + Event::Room, + json!({ "event": "left", "room": "lobby" }), + ); + } + assert_eq!(mirror.take_rooms().len(), BUFFER_LIMIT); + } + + #[test] + fn abandoning_transfers_wakes_whoever_is_waiting_on_them() { + let mirror = Mirror::default(); + let (sender, receiver) = std::sync::mpsc::channel(); + mirror.expect_download("bob", "a.mp3", sender); + + mirror.abandon_downloads(); + + assert!( + matches!(receiver.recv(), Err(std::sync::mpsc::RecvError)), + "a caller blocked on a transfer must be told the daemon is gone, \ + not left to time out" + ); + } + #[test] fn an_unreadable_event_is_ignored_rather_than_fatal() { // A newer daemon may send a shape this client does not know. Dropping diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 60ad427..645ae33 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -170,7 +170,11 @@ impl RemoteSession { std::thread::spawn(move || { read_loop(read, &pending, &mirror); hung_up.store(true, Ordering::Relaxed); + // Everything waiting on this connection has to be told, not + // left to discover it by timing out: calls in flight, and + // transfers whose progress only ever arrived as events. pending.fail_all(); + mirror.abandon_downloads(); }); } @@ -185,7 +189,10 @@ impl RemoteSession { }; let token = if endpoint.self_authenticating() { - token + // Offering a token here can only hurt: the socket's permissions + // have already answered the question, and a stale one on disk + // would turn a connection that should succeed into a refusal. + None } else { Some(token.ok_or_else(|| { network( @@ -250,7 +257,8 @@ impl RemoteSession { { let mut outgoing = self.outgoing.lock().map_err(|_| SoulseekRs::LockPoisoned)?; - if writeln!(outgoing, "{line}").is_err() || outgoing.flush().is_err() + if writeln!(outgoing, "{line}").is_err() + || outgoing.flush().is_err() { self.pending.abandon(id); return Err(SoulseekRs::ConnectionClosed); @@ -328,33 +336,27 @@ impl RemoteSession { username: username.clone(), filename: filename.clone(), size, - // The daemon writes to its own filesystem, which a remote - // caller cannot see and so does not get to choose. - download_directory: None, metadata: (&metadata).into(), }, ) - .inspect_err(|_| self.mirror.forget_download(&username, &filename))?; + .inspect_err(|_| { + self.mirror.forget_download(&username, &filename); + })?; - Ok((rebuild(started.download, sender, metadata), receiver)) + Ok((rebuild(started.download, sender), receiver)) } /// One parameterless request, for the control commands that only need to /// ask a daemon something once. - /// pub fn ask_for(&self, method: Method) -> Result { self.request(method, ()) } - } /// Turn the daemon's view of a transfer back into the library's, with the /// caller's own channel spliced in. -fn rebuild( - dto: DownloadDto, - sender: Sender, - metadata: DownloadMetadata, -) -> Download { +fn rebuild(dto: DownloadDto, sender: Sender) -> Download { + let metadata = dto.metadata.into(); Download { username: dto.username, filename: dto.filename, @@ -381,8 +383,9 @@ fn open(endpoint: &Endpoint) -> Result { network(format!("cannot reach the daemon at {address}: {e}")) })?; let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?; - let closer = - Closer::Tcp(stream.try_clone().map_err(SoulseekRs::NetworkError)?); + let closer = Closer::Tcp( + stream.try_clone().map_err(SoulseekRs::NetworkError)?, + ); Ok((Box::new(read), Box::new(stream), closer)) } Endpoint::Socket(path) => connect_socket(path), @@ -391,9 +394,13 @@ fn open(endpoint: &Endpoint) -> Result { #[cfg(unix)] fn connect_socket(path: &Path) -> Result { - let stream = std::os::unix::net::UnixStream::connect(path).map_err(|e| { - network(format!("cannot reach the daemon at {}: {e}", path.display())) - })?; + let stream = + std::os::unix::net::UnixStream::connect(path).map_err(|e| { + network(format!( + "cannot reach the daemon at {}: {e}", + path.display() + )) + })?; let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?; let closer = Closer::Unix(stream.try_clone().map_err(SoulseekRs::NetworkError)?); @@ -474,14 +481,6 @@ impl SessionApi for RemoteSession { }) } - fn search( - &self, - query: &str, - timeout: Duration, - ) -> Result> { - self.search_with_cancel(query, timeout, None) - } - fn search_with_cancel( &self, query: &str, @@ -577,8 +576,7 @@ impl SessionApi for RemoteSession { // transfers too, and the ones this client started already have // a live channel from `download`. let (sender, _) = channel(); - let metadata = dto.metadata.clone().into(); - rebuild(dto, sender, metadata) + rebuild(dto, sender) }) .collect() } diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 82e8f5b..500971a 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -7,13 +7,13 @@ use crate::cli::{ }; use crate::commands::Ctx; use crate::output::{CliError, CliResult, Exit, Out}; +use crate::ui::launch_main_tui; use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; use std::io::{BufRead, IsTerminal}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::{env, io}; -use crate::ui::launch_main_tui; pub fn run(mut cli: Cli, out: &Out) -> CliResult { let config_path = config_path(&cli); @@ -25,6 +25,15 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { let resolved = crate::persist::config::resolve(&cli, &file_config); let Some(command) = cli.command.take() else { + // Without a terminal the TUI would panic on its first draw. A script + // that reached this point wanted a subcommand, so say that instead of + // crashing. + if !std::io::stdout().is_terminal() { + return Err(CliError::usage( + "the interactive interface needs a terminal; run a subcommand \ + for scripted use (see --help)", + )); + } // A running daemon already holds a session; the TUI attaches to it // rather than logging in a second time, which the server would answer // by cutting the daemon off. @@ -62,7 +71,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { return crate::commands::settings::config_get(out, &store, key); } Commands::Config(ConfigCommand::Set { ref key, ref value }) => { - return crate::commands::settings::config_set(out, &store, key, value); + return crate::commands::settings::config_set( + out, &store, key, value, + ); } Commands::Shares(SharesCommand::List) => { return crate::commands::settings::shares_list( @@ -72,10 +83,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { ); } Commands::Shares(SharesCommand::Add { ref directory }) => { - return crate::commands::settings::shares_add(out, &store, directory); + return crate::commands::settings::shares_add( + out, &store, directory, + ); } Commands::Shares(SharesCommand::Remove { ref directory }) => { - return crate::commands::settings::shares_remove(out, &store, directory); + return crate::commands::settings::shares_remove( + out, &store, directory, + ); } Commands::Wish(WishCommand::Add { ref query }) => { return crate::commands::wish::add(out, &store, query); @@ -97,9 +112,11 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { let token = cli.daemon_token.clone(); return match command { DaemonCommand::Token => crate::commands::daemon::token(out), - DaemonCommand::Status => { - crate::commands::daemon::status(out, endpoint.as_ref(), token) - } + DaemonCommand::Status => crate::commands::daemon::status( + out, + endpoint.as_ref(), + token, + ), DaemonCommand::Stop => { crate::commands::daemon::stop(out, endpoint.as_ref(), token) } @@ -125,7 +142,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Serve(ref args) => { let sweeper = args .wishlist - .then(|| crate::commands::wish::Sweeper::new(store.config.wishes())) + .then(|| { + crate::commands::wish::Sweeper::new(store.config.wishes()) + }) .flatten(); if args.wishlist && sweeper.is_none() { out.warn( @@ -145,7 +164,9 @@ fn config_path(cli: &Cli) -> Option { if cli.no_config { return None; } - cli.config.clone().or_else(crate::persist::paths::config_file) + cli.config + .clone() + .or_else(crate::persist::paths::config_file) } /// Assemble everything a one-shot command needs, demanding the credentials @@ -338,15 +359,6 @@ fn run_default_tui( terminal::{Clear, ClearType}, }; - // Without a terminal the TUI would panic on its first draw. A script that - // reached this point wanted a subcommand, so say that instead of crashing. - if !std::io::stdout().is_terminal() { - return Err(CliError::usage( - "the interactive interface needs a terminal; run a subcommand for \ - scripted use (see --help)", - )); - } - let out = Out::new(false, false); let (server_host, server_port) = parse_server_address(&resolved.server).map_err(CliError::usage)?; @@ -418,8 +430,8 @@ fn run_default_tui( .listen_port() .map(crate::port_mapping::PortMapper::spawn); - let store = - crate::persist::paths::state_dir().map(crate::persist::state::StateStore::new); + let store = crate::persist::paths::state_dir() + .map(crate::persist::state::StateStore::new); launch_main_tui( terminal, @@ -448,19 +460,16 @@ fn run_attached_tui( terminal::{Clear, ClearType}, }; - if !std::io::stdout().is_terminal() { - return Err(CliError::usage( - "the interactive interface needs a terminal; run a subcommand for \ - scripted use (see --help)", - )); - } - let token = cli .daemon_token .clone() .or_else(crate::daemon::stored_token); let session = crate::remote::RemoteSession::connect(endpoint, token) - .map_err(|e| CliError::connection(format!("cannot use the daemon at {endpoint}: {e}")))?; + .map_err(|e| { + CliError::connection(format!( + "cannot use the daemon at {endpoint}: {e}" + )) + })?; soulseek_rs::utils::logger::enable_buffering(); let _ = diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index 7389e74..191a258 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -551,10 +551,8 @@ impl DaemonFixture { static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let state = std::env::temp_dir().join(format!( - "soulseek-e2e-daemon-{}-{n}", - std::process::id() - )); + let state = std::env::temp_dir() + .join(format!("soulseek-e2e-daemon-{}-{n}", std::process::id())); std::fs::create_dir_all(&state).expect("daemon state directory"); let mut args = vec![ @@ -568,9 +566,10 @@ impl DaemonFixture { ]; // A listener of its own unless the test asked for something specific: // transfers arrive on a connection the uploader opens back to us. - if !session_flags.iter().any(|flag| { - flag == "--listener-port" || flag == "--no-listener" - }) { + if !session_flags + .iter() + .any(|flag| flag == "--listener-port" || flag == "--no-listener") + { args.push("--listener-port".to_string()); args.push(free_port().expect("listener port").to_string()); } @@ -631,9 +630,7 @@ impl DaemonFixture { "daemon", "status", ]); - command - .output() - .is_ok_and(|output| output.status.success()) + command.output().is_ok_and(|output| output.status.success()) } } @@ -663,9 +660,7 @@ impl TestServer { child: None, db: None, _gate: gate, - daemons: std::sync::Mutex::new( - std::collections::HashMap::new(), - ), + daemons: std::sync::Mutex::new(std::collections::HashMap::new()), }); }