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
+67 -8
View File
@@ -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