fix(daemon): correct the published contract and harden the control socket

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.
This commit is contained in:
Michel de Graaf
2026-07-27 11:38:46 +02:00
parent a69f52c657
commit 41307d0510
22 changed files with 2528 additions and 1942 deletions
+210 -65
View File
@@ -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<String, Value>,
) -> Vec<Value> {
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<String, Value>,
) -> 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<Value> = 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<String, Value> =
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(&params, &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<Value> = 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<String, Value> =
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 <state-dir>/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 <state-dir>/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::<Vec<_>>(),
@@ -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<String> {
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<String> {
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 {