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:
@@ -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::<RoomEventDto>(event, params) {
|
||||
buffers.rooms.push(room.into());
|
||||
push(&mut buffers.rooms, room.into());
|
||||
}
|
||||
}
|
||||
Event::Message => {
|
||||
if let Ok(message) = parse::<UserMessageDto>(event, params) {
|
||||
buffers.messages.push(message.into());
|
||||
push(&mut buffers.messages, message.into());
|
||||
}
|
||||
}
|
||||
Event::Upload => {
|
||||
if let Ok(upload) = parse::<UploadInfoDto>(event, params) {
|
||||
buffers.uploads.push(upload.into());
|
||||
push(&mut buffers.uploads, upload.into());
|
||||
}
|
||||
}
|
||||
Event::Browse => {
|
||||
if let Ok(browse) = parse::<BrowseEvent>(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<T: Default>(&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<T>(buffer: &mut Vec<T>, item: T) {
|
||||
if buffer.len() >= BUFFER_LIMIT {
|
||||
buffer.remove(0);
|
||||
}
|
||||
buffer.push(item);
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(
|
||||
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
|
||||
|
||||
@@ -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<R: DeserializeOwned>(&self, method: Method) -> Result<R> {
|
||||
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<DownloadStatus>,
|
||||
metadata: DownloadMetadata,
|
||||
) -> Download {
|
||||
fn rebuild(dto: DownloadDto, sender: Sender<DownloadStatus>) -> Download {
|
||||
let metadata = dto.metadata.into();
|
||||
Download {
|
||||
username: dto.username,
|
||||
filename: dto.filename,
|
||||
@@ -381,8 +383,9 @@ fn open(endpoint: &Endpoint) -> Result<Halves> {
|
||||
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<Halves> {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn connect_socket(path: &Path) -> Result<Halves> {
|
||||
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<Vec<SearchResult>> {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user