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:
@@ -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(¶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<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 {
|
||||
|
||||
Reference in New Issue
Block a user